feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s

Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
This commit is contained in:
2026-09-07 12:39:01 -04:00
parent 495d042a98
commit 7fce6572d0
215 changed files with 10142 additions and 1643 deletions
+74 -2
View File
@@ -1,10 +1,18 @@
"""Shared Playwright auth helper (phase 16).
"""Shared Playwright auth helpers (phase 16 + phase 79).
``login`` drives the REAL form login on /login.html (fill → submit →
redirect) so every story that needs the admin does exactly what a human
would — no cookie surgery. ``password=None`` uses the shared E2E admin
password (success path); pass a wrong value to drive the error state
(no redirect, ``#login-error`` role=alert visible, still anonymous).
``login_with_token`` (phase 79) drives the REAL in-app token gate on the
shell the same way (fill → submit): pass an admin-issued token for the
success contract (the gate hides, the app is interactive); call it with
the default all-zeros sentinel — or ``expect_error=True`` for a token
that USED to be valid (revocation) — for the wrong-token contract
(``#auth-gate-error`` role=alert visible, the gate stays, still
anonymous). Task 07's story suite is the first consumer.
"""
from __future__ import annotations
@@ -14,6 +22,53 @@ from e2e.conftest import ADMIN_PASSWORD # noqa: F401 (re-exported for tests)
DEFAULT_NEXT = "/sources.html"
#: The wrong-token sentinel (phase 79): the ``bor_`` prefix plus 32 zero
#: hex chars. The admin issues ``bor_`` + ``secrets.token_hex(16)``, so
#: an all-zeros token is never a live one — the DEFAULT call drives the
#: wrong-token contract without needing a real token at all.
WRONG_TOKEN = "bor_" + "0" * 32
def login_with_token(
page: Page,
app_url: str,
token: str = WRONG_TOKEN,
next: str = "/",
expect_error: bool | None = None,
) -> None:
"""Drive the real in-app token gate (phase 79) on the shell.
``page.goto`` the shell — the test contexts are fresh, so no
``bor.token`` is cached in localStorage and ``#auth-gate`` is the
visible surface — then fill ``#auth-gate-input`` with ``token`` and
submit the gate's form:
* success (an explicitly-passed valid, unrevoked token — with
``expect_error`` left ``None`` the branch is derived from the
all-zeros ``WRONG_TOKEN`` sentinel) → ``#auth-gate`` hides and
the app is interactive (the composer is reachable) at ``next``;
* wrong / revoked (``expect_error=True``, or the sentinel default)
→ ``#auth-gate-error`` (role=alert) is visible, the gate stays
visible — the visible gate IS the anonymity proof, the UI mirror
of ``login``'s wrong-password branch — and the visitor is still
anonymous.
"""
page.goto(app_url + next)
gate = page.locator("#auth-gate")
expect(gate).to_be_visible(timeout=30_000)
page.fill("#auth-gate-input", token)
page.click("#auth-gate-form button")
error_expected = token == WRONG_TOKEN if expect_error is None else expect_error
if error_expected:
error = page.locator("#auth-gate-error")
expect(error).to_have_attribute("role", "alert")
expect(error).to_be_visible(timeout=15_000)
expect(gate).to_be_visible() # the gate is the anonymity proof
return
expect(gate).to_be_hidden(timeout=30_000)
expect(page.locator("#message-input")).to_be_visible() # app is interactive
expect(page).to_have_url(app_url + next)
def login(page: Page, app_url: str, password: str | None = None, next: str | None = None) -> None:
"""Perform the real form login and wait for its outcome.
@@ -22,13 +77,30 @@ def login(page: Page, app_url: str, password: str | None = None, next: str | Non
→ redirects to ``next`` (default ``/sources.html``);
* wrong password → ``#login-error`` (role=alert) is visible, the URL
never changes, and the visitor is still anonymous.
Idempotent (phase 79): in a context that is ALREADY signed in the
login page boots straight to ``next`` (login.js's whoami redirect —
no form) and the call returns as a success; a test may sign in once
per context and call it again from a later section without breaking.
"""
attempt = ADMIN_PASSWORD if password is None else password
url = f"{app_url}/login.html"
if next is not None:
url += f"?next={next}"
page.goto(url)
expect(page.locator("#login-password")).to_be_visible()
if attempt == ADMIN_PASSWORD:
# Phase 79 (task 04): the helper must be IDEMPOTENT — a context
# that is ALREADY signed in never sees the form (login.js's
# boot whoami redirect goes straight to `next`), and a test that
# signs in twice (two sections, one context) must not break on
# the second call.
try:
expect(page.locator("#login-password")).to_be_visible(timeout=2_000)
except AssertionError:
expect(page).to_have_url(app_url + (next or DEFAULT_NEXT), timeout=30_000)
return
else:
expect(page.locator("#login-password")).to_be_visible()
page.fill("#login-password", attempt)
page.click("#login-form button[type=submit]")
if attempt != ADMIN_PASSWORD:
+57 -20
View File
@@ -10,12 +10,19 @@ set (``tests/e2e/conftest.py``); the shared ``tests/e2e/auth_helpers.py::login``
performs the real form login on /login.html.
Test → story mapping (Playwright Mapping Rule):
1. ``test_anonymous_chat_without_tuning``
2. ``test_anonymous_sources_gated_viewer_open``
1. ``test_anonymous_chat_gated_no_tuning``
2. ``test_anonymous_sources_and_viewer_gated``
3. ``test_login_wrong_password_shows_error``
4. ``test_admin_login_unlocks_sources_and_tuning``
5. ``test_logout_returns_to_anonymous``
6. ``test_login_page_a11y``
Phase 79 (API tokens): the anonymous pins moved to the gated contract —
``POST /api/chat`` and ``GET /api/documents/content`` are
``require_user`` (401 ``authentication required`` for anonymous; the
phase-16 "the viewer stays open" soft rule is SUPERSEDED, shared chats
are the only open surface). The password sign-in / sign-out /
wrong-password assertions are UNCHANGED.
"""
from __future__ import annotations
@@ -37,7 +44,6 @@ REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
QUESTION = "How is my Kubernetes cluster set up?"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
DOC_TITLE = "Kubernetes Homelab Cluster"
DOC_VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md"
@@ -88,14 +94,12 @@ def _ask(page: Page, question: str) -> None:
# ---------------------------------------------------------------------------
# 1. Anonymous: chat works, the tuning UI is gone, Sign in is offered
# 1. Anonymous: chat is GATED (phase 79), the tuning UI is gone, Sign in
# is offered
# ---------------------------------------------------------------------------
def test_anonymous_chat_without_tuning(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
def test_anonymous_chat_gated_no_tuning(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
@@ -108,31 +112,48 @@ def test_anonymous_chat_without_tuning(
expect(page.locator("#sign-in-link")).to_have_attribute("href", "/login.html?next=/")
expect(page.locator("#sign-out-btn")).to_be_hidden()
# Chat still streams a grounded answer (with source chips) for
# anonymous visitors…
_ask(page, QUESTION)
expect(page.locator(".msg.brain .source-chip", has_text="kubernetes.md")).to_have_count(1)
# Phase 79: the phase-16 "anonymous chat still streams" pin is
# SUPERSEDED — POST /api/chat is require_user-gated and the
# anonymous browser's own fetch gets the 401 contract (the in-app
# token gate that locks this UI is task 05's surface; the API
# contract is the stable half of the pin).
anon_chat = page.evaluate(
"""async () => {
const r = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: 'hello?'}),
});
return {status: r.status, body: await r.json()};
}"""
)
assert anon_chat["status"] == 401, anon_chat
assert anon_chat["body"] == {"detail": "authentication required"}, anon_chat
# …but the tuning UI is completely gone: no Tune button (new or
# …and the server agrees the visitor is anonymous.
who = page.evaluate("() => fetch('/api/whoami').then((r) => r.json())")
assert who == {"authenticated": False, "role": "anonymous"}
# The tuning UI is completely gone: no Tune button (new or
# restored), no Tuning toggle or panel in the DOM at all.
expect(page.locator(".msg.brain .tune-btn")).to_have_count(0)
expect(page.locator("#steering-toggle")).to_have_count(0)
expect(page.locator("#steering-panel")).to_have_count(0)
# A reload (the phase-14 restore path) must not bring it back.
# A reload must not bring it back.
page.reload()
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
expect(page.locator(".msg.brain .tune-btn")).to_have_count(0)
expect(page.locator("#steering-toggle")).to_have_count(0)
expect(page.locator("#sign-in-link")).to_be_visible()
# ---------------------------------------------------------------------------
# 2. Anonymous: Sources gated, the document viewer stays open (soft rule)
# 2. Anonymous: Sources gated AND the document viewer's DATA is gated
# (phase 79 supersedes the phase-16 soft rule)
# ---------------------------------------------------------------------------
def test_anonymous_sources_gated_viewer_open(
def test_anonymous_sources_and_viewer_gated(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
@@ -160,10 +181,26 @@ def test_anonymous_sources_gated_viewer_open(
# …and NO /api/docs call was ever made.
assert api_docs_calls == [], f"anonymous sources page called /api/docs: {api_docs_calls}"
# The soft rule: any seeded document still opens by direct URL.
# Phase 79 (task 05): the phase-16 soft rule ("any seeded document
# still opens by direct URL") is SUPERSEDED — the content endpoint
# is require_user-gated. The page DOCUMENT still loads (anonymous
# gets the HTML), but the GATED DATA does not: the API refuses
# with 401, and the viewer shows the inline token gate
# (#doc-auth-gate) instead of a content error — the content fetch
# never runs, so no not-found card either.
page.goto(app_url + DOC_VIEWER_URL)
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
expect(page.locator("#doc-content")).not_to_be_empty()
expect(page.locator("#doc-auth-gate")).to_be_visible(timeout=15_000)
expect(page.locator("#doc-not-found")).to_be_hidden()
expect(page.locator("#doc-title")).to_have_text("Loading…")
anon_content = page.evaluate(
"""async () => {
const r = await fetch(
'/api/documents/content?source=docs&path=homelab%2Fkubernetes.md');
return {status: r.status, body: await r.json()};
}"""
)
assert anon_content["status"] == 401, anon_content
assert anon_content["body"] == {"detail": "authentication required"}, anon_content
# ---------------------------------------------------------------------------
+5 -4
View File
@@ -73,6 +73,7 @@ from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document, QueryLog
from e2e.auth_helpers import login
from tests.e2e.mock_llm import embed_text
# --------------------------------------------------------------------------
@@ -358,7 +359,7 @@ def test_marker_question_lists_reads_and_quotes(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, MARKER_QUESTION)
@@ -446,7 +447,7 @@ def test_tool_lines_re_render_after_reload(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_submit(page, MARKER_QUESTION)
_wait_settled(page)
@@ -483,7 +484,7 @@ def test_plain_grounded_question_has_no_tool_frames(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, PLAIN_QUESTION)
@@ -518,7 +519,7 @@ def test_deflected_question_has_no_tool_frames(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, DEFLECT_QUESTION)
+5 -4
View File
@@ -85,6 +85,7 @@ from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document, QueryLog
from e2e.auth_helpers import login
from tests.e2e.mock_llm import embed_text
# --------------------------------------------------------------------------
@@ -392,7 +393,7 @@ def test_multi_read_turn(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, MULTI_QUESTION)
@@ -474,7 +475,7 @@ def test_done_sources_include_reads(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, MULTI_QUESTION)
@@ -513,7 +514,7 @@ def test_relist_allowed(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, MULTI_QUESTION)
@@ -556,7 +557,7 @@ def test_single_tool_flow_regression(
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, SINGLE_QUESTION)
+676
View File
@@ -0,0 +1,676 @@
"""Phase 79 E2E (Playwright): API tokens — the owner's sentence, pinned
in a real browser.
TODO.md L5 (owner 2026-09-06): "Add api tokens that the admin can
generate and hand out so people can log in to use the app. The only
thing that should be accessible without an API token is shared chats.
The web ui should ask for a token before letting a user through and
should cache that token in browser storage so they don't have to keep
entering it."
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_api_tokens.py -v --no-cov
Test → claim mapping (every clause of the owner's sentence is pinned):
1. ``test_anonymous_is_locked_out`` — "ask for a token before letting a
user through": a fresh context meets ``#auth-gate`` on the chat
page, ``#main`` is inert (the composer is NOT keyboard-reachable —
the inverted tab-order walk), and the three gated endpoints
(chat / suggestions / document content) 401 from the context's own
empty cookies.
2. ``test_shared_chats_stay_open_anonymous`` — "the only thing that
should be accessible without an API token is shared chats": the
admin creates + shares a saved chat (the house API pattern), a
FRESH context opens ``/shared/<token>`` anonymously and sees the
conversation rendered — no gate anywhere on that page.
3. ``test_admin_generates_token_in_ui`` — "the admin can generate":
the admin navigates to the Tokens view, labels a token
"e2e-alice" and generates — ``#token-once-value`` carries
``bor_`` + 32 hex (the A4 plaintext-once), the table shows the
Active row, and the once-block is GONE on a re-show (the plaintext
can never be re-shown).
4. ``test_token_user_uses_the_app`` — "hand out so people can log in
and use the app": a fresh context signs in through the real gate
(the task-04 ``login_with_token`` helper), chats end-to-end
(mock LLM), opens a cited document in the same-page modal, and
gets the role-``user`` header contract — every admin nav link
absent, Sign out visible.
5. ``test_cached_token_survives_reload`` — "cache that token in
browser storage so they don't have to keep entering it": the
entered token lands in ``localStorage["bor.token"]``; a reload
re-auths silently — no gate, no re-entry, still role user.
6. ``test_admin_only_walls_403_for_token_user`` — "every existing
admin-only surface stays admin-only": the token user's own session
cookie 403s on tokens / chats / docs / steering / git-sources.
7. ``test_sign_out_clears_the_cached_token`` — sign out clears the
session AND the cached token (one logout, both gone); the gate
comes back.
8. ``test_revocation_closes_the_door`` — "revocation is enforced
IMMEDIATELY": the admin revokes through the UI two-step; the
holder's next gated request 401s (the 401 clears the dead
session cookie — the next whoami is anonymous), and a FRESH login
attempt with the same token is refused at the gate.
9. ``test_wrong_token_is_one_generic_error`` — no enumeration: a
wrong token shows the gate's role=alert line and keeps the
visitor anonymous; the API's error body for a malformed token is
byte-equal to the one for a well-formed unknown token.
DB isolation: the shared e2e Postgres keeps ``api_tokens`` (and
``saved_chats``) rows across suites. This file is the only suite that
issues tokens, so an autouse fixture deletes the ``e2e-``-labeled
rows before each test (never a TRUNCATE — the shared DB may hold the
owner's real tokens); the shared-chat test deletes its own saved row
in a ``finally``. Every scenario runs in its OWN fresh browser
context — no cached token (localStorage) or session cookie leaks
between tests.
"""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
import pytest
from playwright.sync_api import Browser, BrowserContext, 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, login_with_token
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: The plaintext token's shape (owner-locked A4): prefix + 32 hex.
TOKEN_RE = re.compile(r"bor_[0-9a-f]{32}")
#: The five admin-only nav links (the role-`user` contract: ALL of
#: them stay absent for a token user — header.js reveals them only
#: for role === "admin").
ADMIN_NAV_LINKS = ("#nav-sources", "#nav-git-sources", "#nav-tuning", "#nav-history", "#nav-tokens")
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``
and ``api_tokens`` are deliberately NOT touched (the house
pattern)."""
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 _cleanup_e2e_tokens() -> None:
"""Delete this suite's issued tokens (deterministic re-runs).
Label-scoped on ``e2e-`` — never a TRUNCATE: the shared e2e DB is
also the dev DB and may hold the owner's real tokens.
"""
with SessionLocal() as db:
db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'"))
db.commit()
@pytest.fixture(autouse=True)
def _e2e_tokens_clean(db_ready: None) -> None:
"""Start every test from the same token-empty state (this file is
the only E2E suite that issues tokens)."""
_cleanup_e2e_tokens()
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 _cookies(page: Page) -> dict[str, str]:
"""The session cookies the browser context holds (the test's API
side sees exactly what that browser sees)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _create_token(app_url: str, cookies: dict[str, str], label: str) -> tuple[str, str]:
"""Admin-issued token through the house API pattern (httpx + the
signed admin cookie): ``POST /api/tokens`` → 201 (the ONE response
that carries the plaintext, A4). Returns (row id, plaintext)."""
r = httpx.post(f"{app_url}/api/tokens", json={"label": label}, cookies=cookies, timeout=10)
assert r.status_code == 201, r.text
body = r.json()
assert body["label"] == label
assert TOKEN_RE.fullmatch(body["token"]), f"bad token shape: {body}"
return body["id"], body["token"]
def _whoami(page: Page) -> dict[str, Any]:
"""The page's own ``/api/whoami`` read (the context's cookies)."""
return page.evaluate("() => fetch('/api/whoami').then((r) => r.json())")
# ---------------------------------------------------------------------------
# 1. Anonymous is locked out: the gate is up, #main is inert, and the
# three app surfaces 401 (only the shared chats are open — test 2)
# ---------------------------------------------------------------------------
def test_anonymous_is_locked_out(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
js_errors: list[str] = []
page.on("pageerror", lambda e: js_errors.append(str(e)))
page.goto(app_url)
# The gate is the visible surface of an anonymous chat page…
gate = page.locator("#auth-gate")
expect(gate).to_be_visible(timeout=30_000)
expect(gate).to_have_attribute("aria-labelledby", "auth-gate-title")
expect(page.locator("#auth-gate-input")).to_be_focused() # the gate takes the focus
expect(page.locator("#auth-gate-form button[type=submit]")).to_be_visible()
expect(page.locator("#auth-gate-error")).to_have_attribute("role", "alert")
# …and it LOCKS the app: #main is inert while the gate is up, so
# the composer cannot be reached — not by mouse, not by keyboard.
assert page.evaluate("() => document.getElementById('main').inert === true")
# Inverted tab-order walk (the test_suggestion_chips keyboard
# walk, inverted): from the page start, Tab cycles the gate and
# the sign-in link only — the composer is never a tab stop.
seen: list[str] = []
for _ in range(12):
page.keyboard.press("Tab")
seen.append(
page.evaluate(
"() => (document.activeElement && document.activeElement.id) || ''"
)
)
assert "auth-gate-input" in seen, f"the gate input must be keyboard-reachable: {seen}"
assert "message-input" not in seen, (
f"the composer must NOT be keyboard-reachable while the gate is up: {seen}"
)
# The API agrees, from the context's own (empty) cookies: the
# three app surfaces all refuse with ONE 401 detail.
anon_chat = page.evaluate(
"""async () => {
const r = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: 'hello?'}),
});
return {status: r.status, body: await r.json()};
}"""
)
assert anon_chat["status"] == 401, anon_chat
assert anon_chat["body"] == {"detail": "authentication required"}, anon_chat
anon_sugg = page.evaluate(
"""() => fetch('/api/suggestions')
.then((r) => r.json().then((body) => ({status: r.status, body})))"""
)
assert anon_sugg["status"] == 401, anon_sugg
assert anon_sugg["body"] == {"detail": "authentication required"}, anon_sugg
anon_doc = page.evaluate(
"""async () => {
const r = await fetch(
'/api/documents/content?source=docs&path=homelab%2Fkubernetes.md');
return {status: r.status, body: await r.json()};
}"""
)
assert anon_doc["status"] == 401, anon_doc
assert anon_doc["body"] == {"detail": "authentication required"}, anon_doc
# The gated boot itself must be crash-free.
assert not js_errors, f"the gated boot must not throw: {js_errors}"
# ---------------------------------------------------------------------------
# 2. Shared chats stay open: the ONLY anonymous content (the owner's
# sentence) — a fresh context reads the shared conversation with no
# gate anywhere
# ---------------------------------------------------------------------------
def test_shared_chats_stay_open_anonymous(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
# As admin: create + share a saved chat (the house API pattern
# from test_share_chat — httpx with the signed session cookie).
q = "How is my Kubernetes cluster set up? (api-tokens-shared)"
r = httpx.post(
f"{app_url}/api/chats",
json={
"messages": [
{"who": "user", "text": q},
{"who": "brain", "text": "Deterministic mock answer for E2E (api-tokens-shared)"},
]
},
cookies=_cookies(page),
timeout=10,
)
assert r.status_code == 201, r.text
chat_id: str = r.json()["id"]
r = httpx.post(f"{app_url}/api/chats/{chat_id}/share", cookies=_cookies(page), timeout=10)
assert r.status_code == 200, r.text
share_url: str = r.json()["share_url"]
assert share_url.startswith("/shared/"), share_url
title = " ".join(q.split())[:120] # the auto-title convention
anon_ctx: BrowserContext | None = None
try:
# The anonymous JSON snapshot is open (no session at all)…
snap = httpx.get(f"{app_url}/api{share_url}", timeout=10)
assert snap.status_code == 200, snap.text
assert snap.json()["title"] == title
# …and the shared PAGE renders the conversation in a FRESH
# context (no cookies, no cached token) — with no gate
# anywhere on that page: shared chats are the anonymous
# surface, full stop.
anon_ctx = browser.new_context()
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(title)
expect(anon.locator(".msg.user .bubble")).to_contain_text(q)
expect(anon.locator(".msg.brain .bubble")).to_contain_text("api-tokens-shared")
expect(anon.locator(".auth-gate")).to_have_count(0)
expect(anon.locator("#auth-gate, #doc-auth-gate")).to_have_count(0)
# The guest header offers sign-in (the shared page contract).
expect(anon.locator("#sign-in-link")).to_be_visible(timeout=15_000)
finally:
if anon_ctx is not None:
anon_ctx.close()
httpx.delete(f"{app_url}/api/chats/{chat_id}", cookies=_cookies(page), timeout=10)
# ---------------------------------------------------------------------------
# 3. The admin generates a token in the UI: the plaintext appears
# EXACTLY ONCE and the Active row lands in the table; the
# once-block is gone on a re-show
# ---------------------------------------------------------------------------
def test_admin_generates_token_in_ui(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
# The Tokens view is the shell's sixth nav link (the phase-76
# fold) — revealed for the admin, hidden for everyone else.
expect(page.locator("#nav-tokens")).to_be_visible(timeout=15_000)
page.click("#nav-tokens")
expect(page.locator("#view-tokens")).to_be_visible()
expect(page.locator("#tokens-gate")).to_be_hidden() # admin: no sign-in gate
expect(page.locator("#token-create")).to_be_visible(timeout=15_000)
# Generate: label "e2e-alice" → the plaintext appears exactly
# once, in the mono read-only field (the A4 shown-once contract).
page.fill("#token-label", "e2e-alice")
page.click("#token-generate")
expect(page.locator("#token-once")).to_be_visible(timeout=15_000)
token = page.input_value("#token-once-value")
assert TOKEN_RE.fullmatch(token), f"bad token shape: {token!r}"
expect(page.locator("#tokens-status")).to_have_text(
"Token created — copy it now; it won't be shown again."
)
# The table shows the Active row: the em-dash status marker, no
# Revoked pill, a Revoke action.
row = page.locator("#tokens-tbody tr", has_text="e2e-alice")
expect(row).to_have_count(1)
expect(row.locator("td.tokens-label-cell")).to_have_text("e2e-alice")
expect(row.locator("td").nth(3)).to_have_text("—") # Active = the plain em-dash
expect(row.locator(".stale-pill")).to_have_count(0)
expect(row.locator("button.token-revoke")).to_have_count(1)
# The once-block is NOT re-shown on a re-show: nav away (RAG) and
# back — the router's re-show refresh re-runs the list load,
# which hides + wipes the once-block. The plaintext is gone.
page.click("#nav-sources")
expect(page.locator("#view-rag")).to_be_visible()
page.go_back()
expect(page.locator("#view-tokens")).to_be_visible(timeout=15_000)
expect(page.locator("#token-once")).to_be_hidden()
assert page.input_value("#token-once-value") == "", "the plaintext must be wiped on re-show"
# The row survives the re-render (the token itself is unaffected).
expect(page.locator("#tokens-tbody tr", has_text="e2e-alice")).to_have_count(1)
# ---------------------------------------------------------------------------
# 4. The token flow: a fresh context signs in through the real gate
# and USES the app — a grounded chat turn (mock LLM), a cited
# document opened in the same-page modal, the role-user header
# ---------------------------------------------------------------------------
def test_token_user_uses_the_app(
page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db(mock_llm, seed=True) # a deterministic seeded KB for the grounded turn
# The admin generates + hands out the token (fresh context per
# role — the admin's browser is never the holder's browser).
login(page, app_url, next="/")
_id, token = _create_token(app_url, _cookies(page), "e2e-bob")
user_ctx: BrowserContext | None = None
try:
user_ctx = browser.new_context()
user = user_ctx.new_page()
user.set_default_timeout(30_000)
# The holder signs in through the REAL in-app gate (the
# task-04 helper: fill #auth-gate-input → submit → gate hides).
login_with_token(user, app_url, token)
# Use the app: a grounded turn against the seeded KB (mock
# LLM) — the brain bubble renders the deterministic answer.
_ask(user, "How is my Kubernetes cluster set up? (api-tokens-flow)")
# A cited source chip opens the document in the SAME-PAGE
# modal (the require_user content endpoint passes for a
# live token session).
chip = user.locator(".msg.brain a.source-chip").first
expect(chip).to_be_visible(timeout=15_000)
chip.click()
expect(user.locator("#doc-modal")).to_be_visible()
expect(
user.locator("#doc-modal-content .doc-md, #doc-modal-content pre.doc-raw")
).to_have_count(1, timeout=15_000) # the document rendered (not the loading line)
expect(user.locator("#doc-modal-content .doc-modal-loading")).to_have_count(0)
expect(user.locator("#doc-modal-title")).not_to_be_empty()
expect(user.locator("#doc-modal-title")).not_to_have_text("Loading…")
expect(user.locator("#doc-modal-title")).not_to_have_text("Document not found")
# The role-`user` header contract: ALL five admin nav links
# are absent (they reveal only for role === "admin"), and the
# auth pair is the signed-in branch (Sign out, no Sign in).
for link in ADMIN_NAV_LINKS:
expect(user.locator(link)).to_be_hidden()
expect(user.locator("#sign-in-link")).to_be_hidden()
expect(user.locator("#sign-out-btn")).to_be_visible()
# The server agrees: authenticated, role user — NOT admin.
who = _whoami(user)
assert who == {"authenticated": True, "role": "user"}
finally:
if user_ctx is not None:
user_ctx.close()
# ---------------------------------------------------------------------------
# 5. Caching: the token lands in localStorage and a reload re-auths
# silently — no gate, no re-entry
# ---------------------------------------------------------------------------
def test_cached_token_survives_reload(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_id, token = _create_token(app_url, _cookies(page), "e2e-cache")
user_ctx: BrowserContext | None = None
try:
user_ctx = browser.new_context()
user = user_ctx.new_page()
user.set_default_timeout(30_000)
# The gate's success path caches the entered token in
# localStorage (the owner's sentence: "cache that token in
# browser storage").
login_with_token(user, app_url, token)
assert user.evaluate("() => localStorage.getItem('bor.token')") == token
# A reload re-auths SILENTLY from the cache: no gate, no
# re-entry — the chat UI is interactive straight away, the
# lock is released, the role is still user.
user.reload()
expect(user.locator("#auth-gate")).to_be_hidden(timeout=30_000)
expect(user.locator("#message-input")).to_be_visible()
assert user.evaluate("() => document.getElementById('main').inert === false")
assert _whoami(user) == {"authenticated": True, "role": "user"}
# The cache is what re-authed the page — it survived the reload.
assert user.evaluate("() => localStorage.getItem('bor.token')") == token
finally:
if user_ctx is not None:
user_ctx.close()
# ---------------------------------------------------------------------------
# 6. The admin-only walls: every admin surface 403s the token user's
# own session cookie (require_admin, unchanged)
# ---------------------------------------------------------------------------
def test_admin_only_walls_403_for_token_user(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_id, token = _create_token(app_url, _cookies(page), "e2e-wall")
user_ctx: BrowserContext | None = None
try:
user_ctx = browser.new_context()
user = user_ctx.new_page()
user.set_default_timeout(30_000)
login_with_token(user, app_url, token)
cookies = _cookies(user)
# The token user's OWN signed session cookie 403s on every
# admin surface — "admin only" (the phase-16 contract),
# never a 401 (the user IS authenticated — just not an
# admin).
for method, url in (
("GET", "/api/tokens"),
("GET", "/api/chats"),
("GET", "/api/docs"),
("POST", "/api/steering"),
("GET", "/api/git-sources"),
):
r = httpx.request(
method,
app_url + url,
cookies=cookies,
timeout=10,
json={"note": "wall check"} if method == "POST" else None,
)
assert r.status_code == 403, (method, url, r.status_code, r.text)
assert r.json() == {"detail": "admin only"}, (method, url, r.text)
finally:
if user_ctx is not None:
user_ctx.close()
# ---------------------------------------------------------------------------
# 7. Sign out: the header binding clears the session AND the cached
# token — the gate comes back
# ---------------------------------------------------------------------------
def test_sign_out_clears_the_cached_token(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_id, token = _create_token(app_url, _cookies(page), "e2e-signout")
user_ctx: BrowserContext | None = None
try:
user_ctx = browser.new_context()
user = user_ctx.new_page()
user.set_default_timeout(30_000)
login_with_token(user, app_url, token)
assert user.evaluate("() => localStorage.getItem('bor.token')") == token
# Sign out (the header binding): POST /api/logout + drop the
# cached token + reload — ONE logout clears both the server
# session and the localStorage key.
user.click("#sign-out-btn")
expect(user.locator("#auth-gate")).to_be_visible(timeout=30_000)
assert user.evaluate("() => localStorage.getItem('bor.token')") is None
assert _whoami(user) == {"authenticated": False, "role": "anonymous"}
finally:
if user_ctx is not None:
user_ctx.close()
# ---------------------------------------------------------------------------
# 8. Revocation closes the door: the admin's UI two-step kills the
# token immediately — the holder's next request 401s (and the
# 401 clears the dead session cookie), and a fresh login attempt
# with the same token is refused
# ---------------------------------------------------------------------------
def test_revocation_closes_the_door(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_id, token = _create_token(app_url, _cookies(page), "e2e-revoke")
user_ctx: BrowserContext | None = None
fresh_ctx: BrowserContext | None = None
try:
# The holder is signed in (a fresh context of their own).
user_ctx = browser.new_context()
user = user_ctx.new_page()
user.set_default_timeout(30_000)
login_with_token(user, app_url, token)
# The admin revokes through the UI two-step (Revoke → Yes —
# the inline confirm, no native dialog).
page.goto(app_url + "/tokens.html")
row = page.locator("#tokens-tbody tr", has_text="e2e-revoke")
expect(row).to_have_count(1, timeout=15_000)
row.locator("button.token-revoke").click()
expect(row.locator(".history-confirm-yes")).to_be_visible()
row.locator(".history-confirm-yes").click()
revoked = page.locator("#tokens-tbody tr", has_text="e2e-revoke")
expect(revoked.locator(".stale-pill")).to_have_text("Revoked", timeout=15_000)
expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-revoke".')
# Enforcement is IMMEDIATE on the holder's next request (the
# session stores the row id — no server-side session store;
# the live row check IS the revocation check). Driven with the
# USER context's own cookies (its real browser cookie jar —
# response headers land exactly as in a live browser):
#
# 1. whoami is LAZY by contract — the dead session still
# reports "user" (the endpoint does not live-check)…
assert _whoami(user) == {"authenticated": True, "role": "user"}
# 2. …but the next GATED request 401s — and the 401's
# Set-Cookie clears the dead session from the jar…
resp = user.evaluate(
"""async () => {
const r = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: 'hello?'}),
});
return {status: r.status, body: await r.json()};
}"""
)
assert resp["status"] == 401, resp
assert resp["body"] == {"detail": "authentication required"}, resp
# 3. …so from now on the holder is genuinely anonymous.
assert _whoami(user) == {"authenticated": False, "role": "anonymous"}
# A FRESH login attempt with the same (now revoked) token is
# refused at the gate: the error line shows, the gate stays,
# the visitor is still anonymous.
fresh_ctx = browser.new_context()
fresh = fresh_ctx.new_page()
fresh.set_default_timeout(30_000)
login_with_token(fresh, app_url, token, expect_error=True)
assert _whoami(fresh) == {"authenticated": False, "role": "anonymous"}
finally:
if user_ctx is not None:
user_ctx.close()
if fresh_ctx is not None:
fresh_ctx.close()
# ---------------------------------------------------------------------------
# 9. Wrong token: the gate's one generic error, no enumeration — the
# malformed and the well-formed-unknown failures are byte-equal
# ---------------------------------------------------------------------------
def test_wrong_token_is_one_generic_error(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
# The task-04 helper's default sentinel drives the wrong-token
# contract (bor_ + 32 zeros — never a live token): the role=alert
# error line shows, the gate stays, the visitor is anonymous.
login_with_token(page, app_url) # token defaults to the all-zeros sentinel
assert _whoami(page) == {"authenticated": False, "role": "anonymous"}
# No enumeration: the API's ONE generic 401 — the error body for
# a wrong-FORMAT token equals the one for a well-formed but
# unknown token (no shape hint on a credential endpoint).
malformed = httpx.post(
f"{app_url}/api/token-auth", json={"token": "not-a-token-at-all"}, timeout=10
)
unknown = httpx.post(
f"{app_url}/api/token-auth", json={"token": "bor_" + "0" * 32}, timeout=10
)
assert malformed.status_code == 401
assert unknown.status_code == 401
assert malformed.json() == unknown.json() == {"detail": "invalid token"}
+6 -1
View File
@@ -110,7 +110,6 @@ from app.db import SessionLocal
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
MOCK_PORT,
SESSION_SECRET,
USE_REAL_LLM,
@@ -118,6 +117,12 @@ from e2e.conftest import (
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): in a combined session run the
# conftest session app already owns its port — a second uvicorn on it
# dies on bind and this suite would silently drive the wrong server.
# The module app binds its own port instead (env-overridable).
APP_PORT = int(os.environ.get("E2E_APP_PORT_ARCHIVE", "8124"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The slow-LLM proxy's port (the conftest's mock LLM stays on MOCK_PORT).
-250
View File
@@ -1,250 +0,0 @@
"""Phase 22 E2E (Playwright): the background layers — now a regression
suite for the phase-25 no-motion design.
Story: ``.agents/user_stories/background-no-motion.md`` (the phase-25
owner direction supersedes this suite's original pins; the phase-22
history lives in ``.agents/user_stories/background-animation.md``)
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_background_animation.py -v --no-cov
Phase 22 proved the phase-08 background (grid drift + whole-layer
breathe) actually animated in a real Chromium viewport. The owner then
reported (2026-08-25, chat): the background "jitters down and to the
right every second and it slowly blinks brighter and darker. It should
be smooth, fluxuating, dimming and brightening, but not moving.
Different bright spots should slowly fade in and out." — so the
phase-25 redesign (``frontend/assets/styles.css``) removed the
movement entirely: the grid (``body::before``) is a STATIC texture
(``bg-grid-drift`` deleted), and three independent soft glow spots run
their own slow opacity-only fades — ``body::after`` runs ``bg-glow-a``
(26s), ``html::before`` runs ``bg-glow-b`` (34s, −12s delay),
``html::after`` runs ``bg-glow-c`` (42s, −23s delay).
This adapted suite now pins the phase-25 contract on the same
layers (the full story gate is
``tests/e2e/test_background_no_motion.py``): the grid is static, the
indigo spot runs ``bg-glow-a`` and the three spot timelines advance,
all four pseudo-layers keep the fixed/z-index −1/pointer-events
none/no-occlusion contract, and there is no 360px overflow.
Test → story mapping (Playwright Mapping Rule):
1. ``test_grid_layer_is_static`` — computed style of ``body::before``:
``animationName`` is ``"none"`` (the 0.73px/s drift is gone), no
``bg-grid-drift`` entry in the document animation list, and the
static grid ``backgroundImage`` is still painted.
2. ``test_glow_layer_animation_running`` — ``body::after`` runs
``bg-glow-a`` (26s, ease-in-out, infinite) with a matching entry in
the document animation list, ``playState === "running"``.
3. ``test_glow_timelines_advance`` — ``currentTime`` of all three spot
timelines sampled, ~500ms waited, all advanced — the fades are truly
running, not paused (headless Chromium starts the document
animation timeline ~1s after load, so the first sample polls until
the timelines are alive).
4. ``test_background_layers_contracts`` — all four pseudo-elements
(body ``::before``/``::after`` + the phase-25 ``html
::before``/``::after`` spots): ``position: fixed``, ``z-index: -1``,
``pointer-events: none``, ``inset: 0`` (UI Structure Check: behind
content, click-through, full-viewport); the page canvas stays on
``<html>`` (``rgb(15, 10, 10)`` = ``var(--bg)``) and ``<body>``
stays transparent (``rgba(0, 0, 0, 0)``) — the no-occlusion
contract.
5. ``test_no_horizontal_overflow_with_layers`` — at a 360px viewport
``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin,
replicated locally — the ``fixed; inset: 0`` layers must add no
width).
Chromium note: pseudo-element CSS animations are enumerated by
``document.getAnimations()``, NOT by ``document.body.getAnimations()``
(verified on Chromium 151 — the element-level list is empty for
pseudo-layers), so tests 1–3 match on ``animationName`` in the
document-level list. The two ``html`` pseudo-layers' computed styles
come from ``getComputedStyle(document.documentElement,
"::before"/"::after")``.
"""
from __future__ import annotations
import time
from playwright.sync_api import Browser, Page
GRID_OLD = "bg-grid-drift" # deleted in phase 25 — must not exist anywhere
GLOWS = ("bg-glow-a", "bg-glow-b", "bg-glow-c") # the three phase-25 spot fades
PAGE_BG = "rgb(15, 10, 10)" # var(--bg) — the <html> canvas (dark-red rebrand #0f0a0a)
# Computed styles of all four pseudo-layers + the html/body background
# contract (single evaluate — one round-trip per test).
JS_LAYER_REPORT = """() => {
const pick = (el, pseudo) => {
const cs = getComputedStyle(el, pseudo);
return {
anim: cs.animationName,
timing: cs.animationTimingFunction,
iterations: cs.animationIterationCount,
position: cs.position,
zIndex: cs.zIndex,
pointerEvents: cs.pointerEvents,
edges: [cs.top, cs.right, cs.bottom, cs.left],
image: cs.backgroundImage,
};
};
return {
grid: pick(document.body, "::before"),
glowA: pick(document.body, "::after"),
glowB: pick(document.documentElement, "::before"),
glowC: pick(document.documentElement, "::after"),
htmlBg: getComputedStyle(document.documentElement).backgroundColor,
bodyBg: getComputedStyle(document.body).backgroundColor,
};
}"""
# The background-layer animations from the Web Animations API
# ({name, playState, currentTime}); the keyframe names are passed as one
# array argument (Playwright serializes the Python list to a JS array).
JS_TIMELINE = """(names) => document.getAnimations()
.filter((a) => names.includes(a.animationName))
.map((a) => ({
name: a.animationName,
playState: a.playState,
t: a.currentTime,
}))"""
def _timeline(page: Page) -> dict[str, float]:
"""animationName → currentTime (ms) for the three glow-spot layers."""
entries = page.evaluate(JS_TIMELINE, list(GLOWS))
return {str(a["name"]): float(a["t"]) for a in entries}
def _wait_timelines_alive(page: Page, timeout_ms: int = 5000) -> None:
"""Poll until all three spot timelines report currentTime > 0.
Headless Chromium starts the document animation timeline shortly
after load (observed ≈1.4s after navigation) — until then
currentTime is 0, so the "did it advance?" sample in
``test_glow_timelines_advance`` must start once the timeline is
alive.
"""
deadline = time.monotonic() + timeout_ms / 1000
while time.monotonic() < deadline:
times = _timeline(page)
if set(times) == set(GLOWS) and all(times[k] > 0 for k in GLOWS):
return
page.wait_for_timeout(100)
raise AssertionError(
f"background animation timeline never started (saw {_timeline(page)!r})"
)
# --------------------------------------------------------------------------
# Tests (story → test mapping, see module docstring)
# --------------------------------------------------------------------------
def test_grid_layer_is_static(page: Page, app_url: str, db_ready: None) -> None:
"""Phase-25 AC1: the grid layer is STATIC in a real viewport —
``animationName`` is ``"none"``, no ``bg-grid-drift`` animation
exists, and the static grid texture is still painted (the owner
rejected the grid's motion, not the grid)."""
page.goto(app_url)
report = page.evaluate(JS_LAYER_REPORT)
grid = report["grid"]
assert grid["anim"] == "none", (
f"body::before must be static (animationName, got {grid['anim']!r})"
)
live = page.evaluate(JS_TIMELINE, [GRID_OLD])
assert not live, (
f"no {GRID_OLD} animation may exist (got {live!r}) — the drift is deleted"
)
assert grid["image"] != "none", (
f"the static grid texture must still be painted (image {grid['image']!r})"
)
def test_glow_layer_animation_running(page: Page, app_url: str, db_ready: None) -> None:
"""Phase-25 AC2: the indigo spot layer (body::after) runs
bg-glow-a — 26s, ease-in-out, infinite — in a real viewport: the
matching CSSAnimation is reported ``running``."""
page.goto(app_url)
report = page.evaluate(JS_LAYER_REPORT)
glow = report["glowA"]
assert glow["anim"] == "bg-glow-a", (
f"body::after must run bg-glow-a (got {glow['anim']!r})"
)
assert glow["iterations"] == "infinite", (
f"body::after must fade forever (got {glow['iterations']!r})"
)
live = page.evaluate(JS_TIMELINE, list(GLOWS))
match = [a for a in live if a["name"] == "bg-glow-a"]
assert match, "no bg-glow-a entry in document.getAnimations() — spot not fading"
assert match[0]["playState"] == "running", (
f"bg-glow-a is {match[0]['playState']!r} — the spot fade must be running"
)
def test_glow_timelines_advance(page: Page, app_url: str, db_ready: None) -> None:
"""Phase-25 AC2: all three spot timelines actually advance — the
background is a live animation, not a static (or paused) frame.
Sample currentTime, wait ~500ms, and require real progress on all
three layers."""
page.goto(app_url)
_wait_timelines_alive(page)
before = _timeline(page)
page.wait_for_timeout(500)
after = _timeline(page)
for name in GLOWS:
delta = after[name] - before[name]
assert delta >= 200, (
f"{name} timeline did not advance (Δ={delta:.0f}ms < 200ms over 500ms) "
"— paused or static?"
)
def test_background_layers_contracts(page: Page, app_url: str, db_ready: None) -> None:
"""Phase-25 AC4: UI Structure Check — ALL FOUR background
pseudo-layers (body ::before/::after + the phase-25 html
::before/::after spots) stay behind content (fixed, z-index -1,
pointer-events none, full-viewport) and nothing occludes them: the
page canvas is on <html>, <body> transparent."""
page.goto(app_url)
report = page.evaluate(JS_LAYER_REPORT)
for key in ("grid", "glowA", "glowB", "glowC"):
info = report[key]
assert info["position"] == "fixed", f"{key} must stay position:fixed"
assert info["zIndex"] == "-1", (
f"{key} must stay behind content (z-index -1, got {info['zIndex']!r})"
)
assert info["pointerEvents"] == "none", (
f"{key} must stay click-through (pointer-events none)"
)
assert info["edges"] == ["0px", "0px", "0px", "0px"], (
f"{key} must stay full-viewport (inset: 0, got {info['edges']!r})"
)
assert report["htmlBg"] == PAGE_BG, (
f"the page canvas must stay on <html> — var(--bg) (got {report['htmlBg']!r})"
)
assert report["bodyBg"] == "rgba(0, 0, 0, 0)", (
f"body must stay transparent so the layers show (got {report['bodyBg']!r})"
)
def test_no_horizontal_overflow_with_layers(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""Phase-25 AC6: the background layers add no width — the phase-07
overflow pin (documentElement.scrollWidth <= clientWidth) still
holds at the 360px floor with all four fixed; inset: 0 layers
live."""
phone = browser.new_page(viewport={"width": 360, "height": 740})
try:
phone.goto(f"{app_url}/")
scroll, client = phone.evaluate(
"() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]"
)
assert scroll <= client, (
f"horizontal overflow at 360px with the background layers: "
f"{scroll} > {client}"
)
finally:
phone.close()
+151 -317
View File
@@ -1,119 +1,77 @@
"""Phase 25 E2E (Playwright): the background no longer moves — it only fades.
"""Phase 78 E2E (Playwright): the background is fully static.
Story: ``.agents/user_stories/background-no-motion.md`` (supersedes
``background-animation.md``)
Source: ``TODO.md`` L4 — owner direction: "Remove the animated css
background, it's too resource intensive" (supersedes the phase-25
fading-glow contract of the ``background-no-motion`` story; the
superseded chain is 08 → 25 → 78).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_background_no_motion.py -v --no-cov
Owner report (2026-08-25, chat): the phase-22 background "jitters down
and to the right every second and it slowly blinks brighter and darker.
It should be smooth, fluxuating, dimming and brightening, but not
moving. Different bright spots should slowly fade in and out."
The three opacity-fading glow spots (``body::after`` / ``html::before``
/ ``html::after``), their glow keyframes, and the
``prefers-reduced-motion`` rule that stilled them are deleted from
``styles.css`` — the infinite CSS animations ran continuously on every
page, in every tab. The 44px grid texture (``body::before``) STAYS: it
is static (zero animation cost). All other UI animations (typing dots,
spinner, toasts, nav slide, …) are untouched.
The two root causes (phase-22 measurements in
``.agents/reports/22_background_animation/``):
- the "jitter" was the grid's 44px/60s drift (0.73px/s, diagonally
down-right) — a 1px line translated sub-pixel by sub-pixel
rasterizes with per-frame stepping, not smooth motion;
- the "blink" was the whole-layer 14s opacity 0.85↔1 + scale(1)↔
scale(1.05) pulse — one synchronized pulse reads as blinking.
The fix (styles.css, pure CSS, zero JS — A11): the grid (``body::before``)
is a STATIC texture (no animation, ``bg-grid-drift`` deleted), and three
independent soft glow spots each run their own SLOW opacity-only fade —
``body::after`` (phase-08 indigo) runs ``bg-glow-a`` 26s,
``html::before`` (phase-08 cyan) runs ``bg-glow-b`` 34s with −12s delay,
``html::after`` (a third indigo) runs ``bg-glow-c`` 42s with −23s delay.
The out-of-phase 26/34/42s cycles (LCM 4641s) make the total light
fluxuate smoothly and irregularly — no blink, no jitter, no movement.
This suite proves the *behavior* the unit source pins only describe, in
a real Chromium viewport: the grid is static, the three spots run
distinct opacity-only fades whose timelines advance and whose light
measurably changes, no ``bg-*`` keyframe animates anything but
``opacity`` (the deterministic no-movement proof), all four layers keep
the fixed/z-index −1/pointer-events-none/no-occlusion contract,
reduced motion stills all four, and there is no 360px overflow.
This suite proves the *behavior* the unit source pins
(``tests/unit/test_background_no_motion.py``) only describe, in a real
Chromium viewport: the three glow pseudo-elements report no
background-image, ``animation-name: none``, and no box at all (the
rules are gone), no ``bg-*`` keyframes or running ``bg-*`` animations
exist anywhere, the grid is still painted and static, the
no-occlusion canvas contract survives (``<html>`` owns ``var(--bg)``),
reduced motion changes nothing (already static), and there is no
360px overflow.
Test → story mapping (Playwright Mapping Rule):
1. ``test_grid_layer_is_static`` — computed ``animationName`` of
``body::before`` is ``"none"``; no ``bg-grid-drift`` entry in
``document.getAnimations()``; the grid ``backgroundImage`` is still
present (the static texture survives).
2. ``test_three_glow_layers_run_distinct_fades`` — ``body::after`` →
``bg-glow-a`` (26s), ``documentElement::before`` → ``bg-glow-b``
(34s), ``documentElement::after`` → ``bg-glow-c`` (42s); each
``ease-in-out`` + ``infinite`` with a matching
``playState === "running"`` entry in the document animation list;
the three durations are pairwise distinct.
3. ``test_no_motion_properties_in_background_keyframes`` — walks
``document.styleSheets``; across every frame of every ``bg-*``
``KEYFRAMES_RULE`` the set of declared property names is exactly
``{"opacity"}`` — no ``transform``/``background-position`` anywhere
(the deterministic no-movement proof).
4. ``test_glow_timelines_advance`` — poll until all three timelines
report ``currentTime > 0`` (headless Chromium starts the document
timeline ~1s after load), sample, wait ~500ms, each advanced
≥ 200ms.
5. ``test_background_light_actually_changes`` — (a) the computed
``opacity`` of ``body::after`` changes by ≥ 0.05 within ~8s (a real
fade, not a frozen frame); (b) two clipped screenshots ~4s apart of
the bottom-left glow region (the ``html::after`` spot at 14%/86%)
differ in bytes — the light visibly changes while nothing moves
(a fresh ``/`` page has no other animation, so the diff is the
background's).
6. ``test_background_layers_contracts`` — all four pseudo-layers:
``position: fixed``, ``z-index: -1``, ``pointer-events: none``,
top/right/bottom/left all ``0px``; the ``documentElement`` computed
background is ``rgb(15, 10, 10)`` (``var(--bg)`` — the canvas stays
on ``html``); ``document.body`` computed background is
``rgba(0, 0, 0, 0)`` (no occlusion).
7. ``test_reduced_motion_stills_all_layers`` —
``reduced_motion="reduce"`` context: all four pseudo-layers report
computed ``animationName`` ``"none"`` and still carry a
``backgroundImage`` (the static background remains visible).
8. ``test_no_horizontal_overflow_with_layers`` — 360px viewport:
``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin).
Chromium notes (verified on Chromium 151, kept from the phase-22 suite):
pseudo-element CSS animations are enumerated by
``document.getAnimations()``, NOT by
``document.body.getAnimations()`` (the element-level list is empty for
pseudo-layers), so the running/advancing checks match on
``animationName`` in the document-level list. And the computed styles
of the two new ``html`` pseudo-layers come from
``getComputedStyle(document.documentElement, "::before")`` /
``("::after")`` — ``document.body`` only carries the two
``body`` pseudo-layers.
1. ``test_background_layers_computed_styles`` — computed styles:
``body::before`` keeps the grid (``backgroundImage`` non-empty,
``animationName: none``); ``body::after`` / ``html::before`` /
``html::after`` report ``backgroundImage: none`` +
``animationName: none`` + ``position: static`` + ``content: none``
(the pseudo-elements have no box — the rules are deleted, not just
stilled); the page canvas stays on ``<html>`` (``rgb(15, 10, 10)`` =
``var(--bg)``) and ``<body>`` stays transparent (no occlusion).
2. ``test_no_background_keyframes_or_animations`` — deterministic
static proof: no ``@keyframes`` rule with a ``bg-`` name in any
live stylesheet, and no running ``bg-*`` entry in
``document.getAnimations()`` (pseudo-element CSS animations are
enumerated by the document-level list, not the element-level one —
verified on Chromium 151).
3. ``test_grid_layer_is_fixed_behind_content`` — the surviving grid
layer keeps the UI-structure contract: ``position: fixed``,
``z-index: -1``, ``pointer-events: none``, ``inset: 0`` (behind
content, click-through, full-viewport).
4. ``test_reduced_motion_background_static`` — a
``reduced_motion="reduce"`` context: the grid is still painted and
static, the deleted glow layers still report no image/animation —
nothing is resurrected under reduced motion.
5. ``test_no_horizontal_overflow_with_layers`` — 360px viewport:
``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin —
the background adds no width).
"""
from __future__ import annotations
import time
from playwright.sync_api import Browser, Page
from playwright.sync_api import Browser, FloatRect, Page
GLOW_A = "bg-glow-a" # body::after — phase-08 indigo spot, 26s
GLOW_B = "bg-glow-b" # html::before — phase-08 cyan spot, 34s, -12s delay
GLOW_C = "bg-glow-c" # html::after — third indigo spot, 42s, -23s delay
GLOWS = (GLOW_A, GLOW_B, GLOW_C) # the three spot keyframe names
EASE_IN_OUT = "ease-in-out" # computed animationTimingFunction for ease-in-out
PAGE_BG = "rgb(15, 10, 10)" # var(--bg) — the <html> canvas (dark-red rebrand #0f0a0a)
# Computed styles of all four pseudo-layers + the html/body background
# contract (single evaluate — one round-trip per test).
# Computed styles of the surviving grid layer + the three deleted glow
# pseudo-layers + the html/body background contract (single evaluate —
# one round-trip per test).
JS_LAYER_REPORT = """() => {
const pick = (el, pseudo) => {
const cs = getComputedStyle(el, pseudo);
return {
anim: cs.animationName,
duration: cs.animationDuration,
timing: cs.animationTimingFunction,
iterations: cs.animationIterationCount,
position: cs.position,
zIndex: cs.zIndex,
pointerEvents: cs.pointerEvents,
content: cs.content,
edges: [cs.top, cs.right, cs.bottom, cs.left],
image: cs.backgroundImage,
};
@@ -128,24 +86,12 @@ JS_LAYER_REPORT = """() => {
};
}"""
# The three background-layer animations from the Web Animations API
# ({name, playState, currentTime}); the keyframe names are passed as one
# array argument (Playwright serializes the Python list to a JS array).
JS_TIMELINE = """(names) => document.getAnimations()
.filter((a) => names.includes(a.animationName))
.map((a) => ({
name: a.animationName,
playState: a.playState,
t: a.currentTime,
}))"""
# Deterministic no-movement audit: walk every same-origin stylesheet and
# collect, for each @keyframes bg-* rule, the property names declared in
# every keyframe frame. Returns {names: [...], props: [...]} — props must
# be exactly ["opacity"].
JS_KEYFRAME_PROPS = """() => {
const names = [];
const props = new Set();
# Deterministic static audit: walk every same-origin stylesheet and
# collect the names of @keyframes rules starting with "bg-" (must be
# empty), plus the animationNames running in document.getAnimations()
# that start with "bg-" (must be empty).
JS_BG_ANIMATION_AUDIT = """() => {
const keyframeNames = [];
for (const sheet of document.styleSheets) {
let rules;
try {
@@ -155,236 +101,114 @@ JS_KEYFRAME_PROPS = """() => {
}
for (const rule of rules) {
if (rule.type === CSSRule.KEYFRAMES_RULE && rule.name.startsWith("bg-")) {
names.push(rule.name);
for (const frame of rule.cssRules) {
for (const p of frame.style) {
props.add(p);
}
}
keyframeNames.push(rule.name);
}
}
}
return { names, props: [...props] };
const running = document.getAnimations()
.map((a) => a.animationName)
.filter((n) => n && n.startsWith("bg-"));
return { keyframeNames, running };
}"""
def _seconds(value: str) -> float:
"""Chromium reports animation durations as "26s" — parse as seconds."""
return float(str(value).replace("s", ""))
def _timeline(page: Page) -> dict[str, float]:
"""animationName → currentTime (ms) for the three glow layers."""
entries = page.evaluate(JS_TIMELINE, list(GLOWS))
return {str(a["name"]): float(a["t"]) for a in entries}
def _wait_timelines_alive(page: Page, timeout_ms: int = 5000) -> None:
"""Poll until all three glow timelines report currentTime > 0.
Headless Chromium starts the document animation timeline shortly
after load (observed ≈1s after navigation) — until then currentTime
is 0, so the "did it advance?" sample in
``test_glow_timelines_advance`` must start once the timeline is
alive.
"""
deadline = time.monotonic() + timeout_ms / 1000
while time.monotonic() < deadline:
times = _timeline(page)
if set(times) == set(GLOWS) and all(times[k] > 0 for k in GLOWS):
return
page.wait_for_timeout(100)
raise AssertionError(
f"glow animation timelines never started (saw {_timeline(page)!r})"
)
# --------------------------------------------------------------------------
# Tests (story → test mapping, see module docstring)
# --------------------------------------------------------------------------
def test_grid_layer_is_static(page: Page, app_url: str, db_ready: None) -> None:
"""AC1: no movement — the grid layer (body::before) runs NO animation
in a real viewport (the phase-22 0.73px/s drift read as a
once-per-second down-right jitter), and its static texture is still
painted (the grid stays — the owner rejected its motion, not the
grid)."""
def test_background_layers_computed_styles(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC1: the animated background no longer exists in a real viewport —
the three glow pseudo-elements report no background-image and
``animation-name: none``, and they have no box at all
(``position: static`` / ``content: none`` — the CSS rules are
deleted, not merely stilled). The static grid (``body::before``)
stays: its texture is still painted and it carries no animation.
The no-occlusion contract survives: the canvas is on ``<html>``,
``<body>`` transparent."""
page.goto(app_url)
report = page.evaluate(JS_LAYER_REPORT)
grid = report["grid"]
assert grid["anim"] == "none", (
f"body::before must be static (animationName, got {grid['anim']!r})"
)
live = page.evaluate(JS_TIMELINE, ["bg-grid-drift"])
assert not live, (
f"no bg-grid-drift animation may exist (got {live!r}) — the drift is deleted"
)
assert grid["image"] != "none", (
f"the static grid texture must still be painted (image {grid['image']!r})"
)
def test_three_glow_layers_run_distinct_fades(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC2: three different bright spots, each on its own slow fade —
body::after runs bg-glow-a (26s), the two html pseudo-layers run
bg-glow-b (34s) and bg-glow-c (42s); every one is ease-in-out,
infinite, reported ``running`` in the document animation list, and
the three durations are pairwise distinct (out of phase)."""
page.goto(app_url)
report = page.evaluate(JS_LAYER_REPORT)
recipe = (
("glowA", GLOW_A, "26s"),
("glowB", GLOW_B, "34s"),
("glowC", GLOW_C, "42s"),
assert grid["anim"] == "none", (
f"body::before must stay static (animationName, got {grid['anim']!r})"
)
durations: list[float] = []
for key, name, expected in recipe:
for key in ("glowA", "glowB", "glowC"):
info = report[key]
assert info["anim"] == name, (
f"{key} must run {name} (got {info['anim']!r})"
assert info["image"] == "none", (
f"{key}: the deleted glow layer must carry no background-image "
f"(got {info['image']!r})"
)
assert info["duration"] == expected, (
f"{key} must run a {expected} cycle (got {info['duration']!r})"
assert info["anim"] == "none", (
f"{key}: the deleted glow layer must not animate "
f"(got {info['anim']!r})"
)
assert info["timing"] == EASE_IN_OUT, (
f"{key} must ease in-out (got {info['timing']!r})"
)
assert info["iterations"] == "infinite", (
f"{key} must fade forever (got {info['iterations']!r})"
)
durations.append(_seconds(info["duration"]))
assert len(set(durations)) == 3, (
f"the three spot cycles must be out of phase (got {durations})"
)
live = page.evaluate(JS_TIMELINE, list(GLOWS))
for name in GLOWS:
match = [a for a in live if a["name"] == name]
assert match, f"no {name} entry in document.getAnimations() — spot not fading"
assert match[0]["playState"] == "running", (
f"{name} is {match[0]['playState']!r} — the spot fade must be running"
assert info["content"] == "none" and info["position"] == "static", (
f"{key}: the glow pseudo-element must have no box "
f"(content {info['content']!r}, position {info['position']!r})"
)
def test_no_motion_properties_in_background_keyframes(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC1: the deterministic no-movement proof — walk the live stylesheets
in Chromium: across every frame of every ``bg-*`` @keyframes rule the
set of declared properties is exactly {opacity}. No transform, no
background-position, nothing that can move a pixel."""
page.goto(app_url)
audit = page.evaluate(JS_KEYFRAME_PROPS)
names = set(str(n) for n in audit["names"])
assert names == {GLOW_A, GLOW_B, GLOW_C}, (
f"expected exactly the three spot keyframes {sorted({GLOW_A, GLOW_B, GLOW_C})}, "
f"found {sorted(names)}"
)
props = {str(p) for p in audit["props"]}
assert props == {"opacity"}, (
f"bg-* keyframes may only animate opacity (found {sorted(props)}) — "
"the background must not move"
)
def test_glow_timelines_advance(page: Page, app_url: str, db_ready: None) -> None:
"""AC2: all three spot timelines actually advance — the background is
a live animation, not a static (or paused) frame. Sample
currentTime, wait ~500ms, and require real progress on all three
layers (the document animation timeline in headless Chromium starts
~1s after load, so poll until it is alive first)."""
page.goto(app_url)
_wait_timelines_alive(page)
before = _timeline(page)
page.wait_for_timeout(500)
after = _timeline(page)
for name in GLOWS:
delta = after[name] - before[name]
assert delta >= 200, (
f"{name} timeline did not advance (Δ={delta:.0f}ms < 200ms over 500ms) "
"— paused or static?"
)
def test_background_light_actually_changes(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC2/AC3: the light fluxuates — (a) the computed opacity of
body::after measurably changes within a few seconds (a real fade,
not a frozen frame), and (b) a clipped screenshot of the bottom-left
glow region (the html::after spot at 14%/86% of the 1280×800
viewport) differs in bytes ~4s later — the light visibly changes
while nothing moves (a fresh / page runs no other animation, so the
pixel diff is the background's)."""
page.goto(app_url)
# (a) computed opacity of the indigo spot fades by >= 0.05 within ~8s.
def _spot_opacity() -> float:
return float(
page.evaluate("() => getComputedStyle(document.body, '::after').opacity")
)
t0 = _spot_opacity()
deadline = time.monotonic() + 8.0
delta = 0.0
while time.monotonic() < deadline:
delta = abs(_spot_opacity() - t0)
if delta >= 0.05:
break
page.wait_for_timeout(100)
assert delta >= 0.05, (
f"body::after opacity did not fade (Δ={delta:.3f} < 0.05 over 8s) — "
"frozen frame?"
)
# (b) the rendered glow region changes over ~4s (bottom-left spot at
# 14%/86% ≈ (179px, 688px) in the 1280×800 viewport).
clip: FloatRect = {"x": 0, "y": 500, "width": 500, "height": 300}
shot_1 = page.screenshot(clip=clip)
page.wait_for_timeout(4000)
shot_2 = page.screenshot(clip=clip)
assert shot_1 != shot_2, (
"the clipped bottom-left glow region is byte-identical 4s apart — "
"the light must visibly change even though nothing moves"
)
def test_background_layers_contracts(page: Page, app_url: str, db_ready: None) -> None:
"""AC4: UI Structure Check — all four background pseudo-layers (body
::before/::after + the two new html ::before/::after spots) stay
behind content (fixed, z-index -1, pointer-events none, full-viewport)
and nothing occludes them: the page canvas stays on <html>, <body>
stays transparent."""
page.goto(app_url)
report = page.evaluate(JS_LAYER_REPORT)
for key in ("grid", "glowA", "glowB", "glowC"):
info = report[key]
assert info["position"] == "fixed", f"{key} must stay position:fixed"
assert info["zIndex"] == "-1", (
f"{key} must stay behind content (z-index -1, got {info['zIndex']!r})"
)
assert info["pointerEvents"] == "none", (
f"{key} must stay click-through (pointer-events none)"
)
assert info["edges"] == ["0px", "0px", "0px", "0px"], (
f"{key} must stay full-viewport (inset: 0, got {info['edges']!r})"
)
assert report["htmlBg"] == PAGE_BG, (
f"the page canvas must stay on <html> — var(--bg) (got {report['htmlBg']!r})"
)
assert report["bodyBg"] == "rgba(0, 0, 0, 0)", (
f"body must stay transparent so the layers show (got {report['bodyBg']!r})"
f"body must stay transparent so the grid shows (got {report['bodyBg']!r})"
)
def test_reduced_motion_stills_all_layers(
def test_no_background_keyframes_or_animations(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC1: the deterministic static proof — no ``@keyframes`` rule in
the background keyframe namespace (the ``bg-`` prefix) exists in any
live stylesheet, and no background-namespaced animation is running
in ``document.getAnimations()`` (the owner removed the infinite CSS
animations entirely — they ran on every page, in every tab)."""
page.goto(app_url)
audit = page.evaluate(JS_BG_ANIMATION_AUDIT)
assert not audit["keyframeNames"], (
f"no bg-* @keyframes may remain (found {audit['keyframeNames']!r})"
)
assert not audit["running"], (
f"no bg-* animation may be running (found {audit['running']!r})"
)
def test_grid_layer_is_fixed_behind_content(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC2: UI Structure Check — the surviving grid layer stays behind
the content and can never intercept input: ``position: fixed``,
``z-index: -1``, ``pointer-events: none``, full-viewport
(``inset: 0``)."""
page.goto(app_url)
grid = page.evaluate(JS_LAYER_REPORT)["grid"]
assert grid["position"] == "fixed", "body::before must stay position:fixed"
assert grid["zIndex"] == "-1", (
f"the grid must stay behind content (z-index -1, got {grid['zIndex']!r})"
)
assert grid["pointerEvents"] == "none", (
"the grid must stay click-through (pointer-events none)"
)
assert grid["edges"] == ["0px", "0px", "0px", "0px"], (
f"the grid must stay full-viewport (inset: 0, got {grid['edges']!r})"
)
def test_reduced_motion_background_static(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC5: with prefers-reduced-motion: reduce ALL FOUR pseudo-layers
stop animating (animation-name: none) — the static grid + spot
images remain visible."""
"""AC3: with ``prefers-reduced-motion: reduce`` the background is
already fully static — the grid is still painted and reports
``animation-name: none``, and the deleted glow layers still report
no image/animation (nothing is resurrected; the reduced-motion
blocks that survive in styles.css cover UI animations only)."""
context = browser.new_context(
reduced_motion="reduce", viewport={"width": 1280, "height": 800}
)
@@ -392,13 +216,23 @@ def test_reduced_motion_stills_all_layers(
rpage = context.new_page()
rpage.goto(app_url)
report = rpage.evaluate(JS_LAYER_REPORT)
for key in ("grid", "glowA", "glowB", "glowC"):
grid = report["grid"]
assert grid["image"] != "none", (
f"the static grid must remain visible under reduced motion "
f"(image {grid['image']!r})"
)
assert grid["anim"] == "none", (
f"the grid must stay static under reduced motion (got {grid['anim']!r})"
)
for key in ("glowA", "glowB", "glowC"):
info = report[key]
assert info["anim"] == "none", (
f"{key} must not animate under reduced motion (got {info['anim']!r})"
assert info["image"] == "none", (
f"{key}: no glow background may exist under reduced motion "
f"(got {info['image']!r})"
)
assert info["image"] != "none", (
f"{key}: the static background image must remain visible"
assert info["anim"] == "none", (
f"{key}: no glow animation may exist under reduced motion "
f"(got {info['anim']!r})"
)
finally:
context.close()
@@ -407,9 +241,9 @@ def test_reduced_motion_stills_all_layers(
def test_no_horizontal_overflow_with_layers(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC6: the four background layers add no width — the phase-07
overflow pin (documentElement.scrollWidth <= clientWidth) still holds
at the 360px floor with all four fixed; inset: 0 layers live."""
"""AC4: the background adds no width — the phase-07 overflow pin
(``documentElement.scrollWidth <= clientWidth``) holds at the 360px
floor with the single fixed; inset: 0 grid layer."""
phone = browser.new_page(viewport={"width": 360, "height": 740})
try:
phone.goto(f"{app_url}/")
+8 -5
View File
@@ -70,6 +70,7 @@ 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"
@@ -347,7 +348,7 @@ def test_bottom_cluster_pinned_at_every_scroll_position(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
build_conversation(page, n=len(SHORT_QUESTIONS))
@@ -434,9 +435,11 @@ def test_row_geometry_and_alignment(
) -> None:
page.set_default_timeout(30_000)
# No conversation needed for the row — static markup, always present
# (the page fixture's viewport is the 1280×800 desktop).
page.goto(app_url)
# No conversation needed for the row's GEOMETRY — static markup,
# always present (the page fixture's viewport is the 1280×800
# desktop). Phase 79: the touch-target section below seeds ONE
# turn, so the visitor signs in first (chat is require_user).
login(page, app_url, next="/")
# Exactly ONE .chat-actions, inside the pinned unit, holding exactly
# the two pills — and the A5 DOM order: New chat, then Share (the
@@ -546,7 +549,7 @@ def test_buttons_still_work_from_the_bottom(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# One real conversation: the buttons have something to act on (and
# the auto-save path fires — the row is cleaned up by _reset_db).
+11 -3
View File
@@ -23,7 +23,7 @@ from typing import Any
import httpx
from playwright.sync_api import Page
from e2e.auth_helpers import login
from e2e.auth_helpers import ADMIN_PASSWORD, login
REPO = Path(__file__).resolve().parents[2]
CHAT_QUESTION = "How is my Kubernetes cluster set up?"
@@ -60,9 +60,17 @@ def _admin_cookies(page: Page) -> dict[str, str]:
def _stream_chat_frames(app_url: str, message: str) -> list[dict[str, Any]]:
"""Minimal SSE chat request (same pattern as ``test_chat_rag.py``):
POST /api/chat and collect the ``data:`` frames until the stream ends."""
POST /api/chat and collect the ``data:`` frames until the stream ends.
Phase 79: POST /api/chat is require_user-gated — the httpx client
signs in as the admin first (the middleware under test never touches
the auth contract; this is purely the request's credentials)."""
client = httpx.Client(timeout=60.0)
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204
frames: list[dict[str, Any]] = []
with httpx.stream(
with client.stream(
"POST", f"{app_url}/api/chat", json={"message": message}, timeout=60.0
) as r:
assert r.status_code == 200
+8 -7
View File
@@ -130,7 +130,7 @@ def test_conversation_survives_reload(
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_ask(page, QUESTION)
# The turn is persisted: versioned payload, RAW text (no HTML), and the
@@ -179,7 +179,7 @@ def test_deflected_turn_restores_styling(
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_ask_deflected(page, OFF_TOPIC)
# The deflected metadata (suggestions) is persisted with the answer.
@@ -226,7 +226,7 @@ def test_new_chat_clears_conversation(
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_ask(page, QUESTION)
expect(page.locator(".msg")).to_have_count(2)
assert _stored(page) is not None
@@ -267,18 +267,19 @@ def test_persists_across_page_navigation(
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_ask(page, QUESTION)
_ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected
# A trip to Sources (phase 16: the catalog is admin-only — the trip
# starts with a real form login). Phase 76 (task 02): the shell
# A trip to Sources (phase 16: the catalog is admin-only — the
# test's form login above already holds the session, so the trip is
# a plain navigation). Phase 76 (task 02): the shell
# carries the chat view (with its New chat button) in the DOM on
# EVERY view — hidden + inert — so the button EXISTS here but must
# be HIDDEN (the view-scoped absence pattern; it left the shared
# bar at owner request, 2026-08-28 — pinned in
# tests/e2e/test_shared_header.py).
login(page, app_url, next="/sources.html")
page.goto(f"{app_url}/sources.html")
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#new-chat-btn")).to_be_hidden()
+11 -3
View File
@@ -27,6 +27,7 @@ from app.db import SessionLocal
from app.models import QueryLog
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import ADMIN_PASSWORD, login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -78,7 +79,7 @@ def test_on_topic_question_streams_grounded_answer(
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# Healthy KB: the offline banner must stay hidden.
expect(page.locator("#kb-banner")).to_be_hidden()
@@ -117,7 +118,7 @@ def test_on_topic_question_streams_grounded_answer(
def test_chat_logs_query(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", QUESTION)
page.click("#send-btn")
expect(
@@ -147,8 +148,15 @@ def test_sse_stream_shape(app_url: str, mock_llm: int, db_ready: None) -> None:
"""Raw transport contract (PLAN §4): delta events, then one done."""
_reset_db(mock_llm, seed=True)
# Phase 79: POST /api/chat is require_user-gated — the httpx client
# signs in as the admin first (the form login's API side: 204 + the
# signed session cookie in the jar).
client = httpx.Client(timeout=60.0)
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204
frames: list[dict[str, Any]] = []
with httpx.stream(
with client.stream(
"POST", f"{app_url}/api/chat", json={"message": QUESTION}, timeout=60.0
) as r:
assert r.status_code == 200
+2 -1
View File
@@ -52,6 +52,7 @@ import httpx
import pytest
from playwright.sync_api import Page, expect
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
@@ -215,7 +216,7 @@ def test_other_pages_titles_carry_the_name(page: Page, testy_server: str) -> Non
def test_chat_status_label_uses_custom_name_pre_token(
page: Page, testy_server: str, db_ready: None
) -> None:
page.goto(testy_server + "/")
login(page, testy_server, next="/")
expect(page.locator("#send-btn")).to_be_enabled(timeout=15_000)
# The ``think out loud`` marker (mock_llm.py) makes the answer
+58 -54
View File
@@ -1,4 +1,4 @@
"""Phase 08 E2E (Playwright): dark tech theme — palette, no emoji, animated
"""Phase 08 E2E (Playwright): dark tech theme — palette, no emoji, static
background, reduced motion, behavior intact, all assets local.
Story: ``.agents/user_stories/dark-tech-theme.md``
@@ -13,14 +13,17 @@ Test → story mapping (Playwright Mapping Rule):
(same contrast helper as Phase 07).
2. ``test_no_emoji_in_chrome`` — neither page's ``innerText`` nor raw
``outerHTML`` contains any emoji code point.
3. ``test_animated_background`` — the background layers (fixed,
pointer-events none, carrying images): the grid (``body::before``)
is STATIC (phase 25, owner 2026-08-25: no movement) and the three
glow spots run their opacity-only fades ``bg-glow-a/b/c`` at
26s/34s/42s (``body::after`` + the two ``html`` pseudo-layers).
3. ``test_static_background`` — the background is fully STATIC (phase
78, owner direction: the animated glow layers were removed as too
resource-intensive): the grid (``body::before``) is a static
texture (fixed, pointer-events none, image still painted,
``animationName: none``) and the three glow pseudo-layers
(``body::after`` + the two ``html`` pseudo-layers) are deleted —
no background-image, no animation, no box.
4. ``test_reduced_motion_honored`` — a context with
``reduced_motion="reduce"`` → ``animation-name: none`` on all four
layers (the static grid + spot images remain).
``reduced_motion="reduce"`` → the grid stays painted and static
(``animation-name: none``) and the deleted glow pseudo-layers carry
no image or animation.
5. ``test_behavior_unchanged_smoke`` — on-topic question streams an answer
+ a source chip + the send button recovers (state machine intact under
the new skin).
@@ -36,7 +39,6 @@ from pathlib import Path
from threading import Thread
from typing import Any
import pytest
from playwright.sync_api import Browser, Page, expect
from sqlalchemy import text
@@ -156,11 +158,6 @@ def _assert_aa(pair: Any, label: str) -> None:
assert ratio >= 4.5, f"contrast {label}: {fg} on {bg} = {ratio:.2f}:1 (< 4.5:1)"
def _seconds(value: str) -> float:
"""Chromium reports animation durations as "60s" — parse as seconds."""
return float(str(value).replace("s", ""))
# --------------------------------------------------------------------------
# Tests (story → test mapping, see module docstring)
# --------------------------------------------------------------------------
@@ -176,8 +173,8 @@ def test_dark_palette_and_contrast(
# The visible page background is the <html> canvas (rgb(15, 10, 10)
# = #0f0a0a — the 2026-08-28 dark-red rebrand; it was the phase-08
# #0a0e17 canvas). <body> itself stays transparent so the
# z-index:-1 grid/glow layers (test_animated_background) are not
# painted over.
# z-index:-1 grid layer (test_static_background) is not painted
# over.
bg = page.evaluate(
"() => getComputedStyle(document.documentElement).backgroundColor"
)
@@ -187,8 +184,10 @@ def test_dark_palette_and_contrast(
f"{path}: body must stay transparent (the background layers need to show)"
)
# Chat page pairs.
page.goto(f"{app_url}/")
# Chat page pairs. Phase 79: the onboarding chips are drawn from
# /api/suggestions (require_user-gated) — the visitor signs in
# first; the palette pins are auth-independent.
login(page, app_url, next="/")
page.locator("#suggestions .suggestion-chip").first.wait_for(state="visible", timeout=10_000)
pairs = page.evaluate(
"""() => {
@@ -260,16 +259,15 @@ def test_no_emoji_in_chrome(page: Page, app_url: str, db_ready: None) -> None:
assert not hits, f"emoji in {label} on {path}: {sorted(named)}"
def test_animated_background(page: Page, app_url: str, db_ready: None) -> None:
"""AC3: the background is subtly animated, pure CSS, zero JS, and can
never block or dim content. Phase-25 contract (owner 2026-08-25:
"not moving. Different bright spots should slowly fade in and out")
supersedes the phase-08 recipe: the grid (``body::before``) is a
STATIC texture (``animationName: none``, image still painted) and
the three glow spots each run their own opacity-only fade —
``body::after`` → ``bg-glow-a`` 26s, ``html::before`` →
``bg-glow-b`` 34s, ``html::after`` → ``bg-glow-c`` 42s — all fixed,
pointer-events:none, carrying an image."""
def test_static_background(page: Page, app_url: str, db_ready: None) -> None:
"""AC3: the background is fully static, pure CSS, zero JS, and can
never block or dim content. Phase-78 contract (owner direction,
TODO.md L4: the animated background was removed as too
resource-intensive) supersedes the phase-25 recipe: the grid
(``body::before``) is a STATIC texture (``animationName: none``,
image still painted, fixed, pointer-events:none), and the three
glow spots (``body::after`` + the two ``html`` pseudo-layers) are
DELETED — no background-image, no animation, no box."""
page.goto(app_url)
report = page.evaluate(
"""() => {
@@ -278,7 +276,6 @@ def test_animated_background(page: Page, app_url: str, db_ready: None) -> None:
return {
image: cs.backgroundImage,
anim: cs.animationName,
duration: cs.animationDuration,
position: cs.position,
pointerEvents: cs.pointerEvents,
};
@@ -291,34 +288,32 @@ def test_animated_background(page: Page, app_url: str, db_ready: None) -> None:
};
}"""
)
for key in ("grid", "glowA", "glowB", "glowC"):
info = report[key]
assert info["image"] != "none", f"{key} must carry a background image"
assert info["position"] == "fixed", f"{key} must be position:fixed"
assert info["pointerEvents"] == "none", f"{key} must not intercept input"
# The phase-25 recipe: a static grid + three out-of-phase spot fades.
assert report["grid"]["anim"] == "none", (
f"the grid must be static (got {report['grid']['anim']!r})"
grid = report["grid"]
assert grid["image"] != "none", (
"the static grid must carry a background image"
)
for key, name, seconds in (
("glowA", "bg-glow-a", 26.0),
("glowB", "bg-glow-b", 34.0),
("glowC", "bg-glow-c", 42.0),
):
assert grid["anim"] == "none", f"the grid must be static (got {grid['anim']!r})"
assert grid["position"] == "fixed", "the grid must be position:fixed"
assert grid["pointerEvents"] == "none", "the grid must not intercept input"
# The phase-78 recipe: the animated glow layers are gone entirely.
for key in ("glowA", "glowB", "glowC"):
info = report[key]
assert info["anim"] == name, f"{key} must run {name} (got {info['anim']!r})"
assert _seconds(info["duration"]) == pytest.approx(seconds), (
f"{key} must run its {seconds:.0f}s fade (got {info['duration']!r})"
assert info["image"] == "none", (
f"{key}: the deleted glow layer must carry no background image "
f"(got {info['image']!r})"
)
assert info["anim"] == "none", (
f"{key}: the deleted glow layer must not animate (got {info['anim']!r})"
)
def test_reduced_motion_honored(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC4: with prefers-reduced-motion: reduce ALL FOUR background
layers stop animating (animation-name: none) — the static grid +
spot images remain (phase 25 stills the two html pseudo-layers as
well as the body pair)."""
"""AC4: with prefers-reduced-motion: reduce the background is
already fully static (phase 78 deleted the animated glow layers) —
the grid stays painted and static (``animation-name: none``) and
the deleted glow pseudo-layers carry no image or animation."""
context = browser.new_context(
reduced_motion="reduce", viewport={"width": 1280, "height": 800}
)
@@ -339,12 +334,21 @@ def test_reduced_motion_honored(
};
}"""
)
for key in ("grid", "glowA", "glowB", "glowC"):
grid = report["grid"]
assert grid["anim"] == "none", (
f"the grid must stay static under reduced motion (got {grid['anim']!r})"
)
assert grid["image"] != "none", (
"grid: the static background image must remain visible"
)
for key in ("glowA", "glowB", "glowC"):
assert report[key]["anim"] == "none", (
f"{key} must not animate under reduced motion (got {report[key]['anim']!r})"
f"{key}: no glow animation may exist under reduced motion "
f"(got {report[key]['anim']!r})"
)
assert report[key]["image"] != "none", (
f"{key}: the static background image must remain visible"
assert report[key]["image"] == "none", (
f"{key}: no glow image may exist under reduced motion "
f"(got {report[key]['image']!r})"
)
finally:
context.close()
@@ -358,7 +362,7 @@ def test_behavior_unchanged_smoke(
button recovers (never stale)."""
_seed_kb(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
expect(page.locator("#kb-banner")).to_be_hidden()
page.fill("#message-input", QUESTION)
+2 -1
View File
@@ -104,7 +104,7 @@ def test_back_from_chat_returns_to_chat(
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat AND the viewer content are gated
page.fill("#message-input", QUESTION)
page.click("#send-btn")
@@ -187,6 +187,7 @@ def test_malicious_back_param_is_rejected(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
login(page, app_url, next="/") # phase 79: the viewer content is gated
errors: list[str] = []
dialogs: list[str] = []
page.on("pageerror", lambda e: errors.append(str(e)))
+2 -1
View File
@@ -48,6 +48,7 @@ from app.rag.chunker import chunk_document
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from app.rag.retriever import RetrievedChunk, retrieve
from e2e.auth_helpers import login
from tests.e2e.mock_llm import TOKEN_RE, embed_text
REPO = Path(__file__).resolve().parents[2]
@@ -116,7 +117,7 @@ def _reset_db(seed: Callable[[Session], None] | None = None) -> None:
def _ask(page: Page, app_url: str, question: str) -> Any:
"""Submit *question* and wait for the streamed brain bubble."""
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", question)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble")
+5 -1
View File
@@ -92,7 +92,7 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
def _ask_for_chip(page: Page, app_url: str) -> Any:
"""Drive one chat turn and return the kubernetes.md source chip."""
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", QUESTION)
page.click("#send-btn")
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
@@ -342,6 +342,10 @@ def test_standalone_page_still_works(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
# Phase 79: the viewer content is gated — the signed-in session sees
# the phase-10 contract unchanged (the not-found cards below come
# from the 404 / missing-params paths, not the auth gate).
login(page, app_url, next="/")
errors: list[str] = []
page.on("pageerror", lambda e: errors.append(str(e)))
+20 -23
View File
@@ -36,8 +36,9 @@ 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).
D4 re-embed scope) and against the content endpoint (phase 79 superseded the
phase-16 soft rule: the endpoint is require_user, so the pin runs
under the admin session — the shape is unchanged).
"""
from __future__ import annotations
@@ -57,7 +58,7 @@ 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.auth_helpers import ADMIN_PASSWORD, login
from e2e.mock_llm import TOKEN_RE
REPO = Path(__file__).resolve().parents[2]
@@ -183,12 +184,16 @@ def _chunk_state() -> dict[str, Any]:
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(
"""The content endpoint's ``summary``. Phase 79: the endpoint is
require_user (the phase-16 soft rule is superseded) — the fresh
httpx client signs in as the admin first (every caller is an
admin-flow test)."""
client = httpx.Client(timeout=10)
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204
r = client.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"]
@@ -334,27 +339,19 @@ 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>.
# Fresh context (the function-scoped page fixture — no login).
# Phase 79 (task 05): the content endpoint is require_user — the
# anonymous viewer meets the inline token gate (the content fetch
# never runs — no summary panel, no not-found card either), so the
# edit affordance is absent by construction.
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-auth-gate")).to_be_visible(timeout=15_000)
expect(page.locator("#doc-title")).to_have_text("Loading…")
expect(page.locator(".doc-summary")).to_have_count(0)
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
+6 -1
View File
@@ -74,13 +74,18 @@ from app.models import GitSource
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]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_GITADMIN", "8125"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The five pages that ship the header (phase 34 contract) — the pages
+35 -4
View File
@@ -516,6 +516,12 @@ def test_anonymous_cannot_manage(
expect(page.locator("#nav-sources")).to_be_hidden()
expect(page.locator("#nav-tuning")).to_be_hidden()
# Phase 79 (task 05): the anonymous visitor meets the token gate —
# the whole page (list AND create form) sits inert behind it, so
# "anonymous cannot manage" is enforced by the gate first.
expect(page.locator("#auth-gate")).to_be_visible(timeout=15_000)
assert page.evaluate("() => document.getElementById('main').inert") is True
# The list stays on its empty state even though a note exists…
expect(page.locator("#tune-list .tuning-note")).to_have_count(0)
expect(page.locator("#tune-empty")).to_be_visible()
@@ -536,15 +542,40 @@ def test_anonymous_cannot_manage(
row = db.get(SteeringNote, note_id)
assert row is not None and row.note == SEED_NOTE, "the note must stay untouched"
# The create form 403s gracefully: an inline error (role=alert) with
# the API detail, and the typed instruction survives in the textarea.
page.fill("#tune-note", "an anonymous attempt")
# The gate is only the first lock: the create form 403s gracefully
# for a NON-ADMIN (token user) too — an inline error (role=alert)
# with the API detail, and the typed instruction survives in the
# textarea. Admin signs in via the header, mints a token, signs out
# again, then unlocks the page through the gate as that user.
login(page, app_url, next=TUNING_URL)
expect(page.locator("#nav-tuning")).to_be_visible(timeout=15_000)
token = page.evaluate(
"""async () => {
const r = await fetch('/api/tokens', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({label: 'e2e-tuning-user'}),
});
if (!r.ok) throw new Error('token create failed: ' + r.status);
return (await r.json()).token;
}"""
)
page.click("#sign-out-btn")
page.wait_for_selector("#auth-gate:not([hidden])", timeout=15_000)
page.fill("#auth-gate-input", token)
page.click("#auth-gate-form button")
page.wait_for_selector("#auth-gate", state="hidden", timeout=15_000)
# The token user unlocked the page but is still NOT admin: the
# Tuning nav link stays hidden, the form is reachable only by
# direct URL — and the API refuses the create with 403.
expect(page.locator("#nav-tuning")).to_be_hidden()
page.fill("#tune-note", "a non-admin attempt")
page.click("#tune-save")
error = page.locator("#tune-form .tuning-error")
expect(error).to_have_attribute("role", "alert")
expect(error).to_be_visible(timeout=15_000)
assert "admin only" in (error.inner_text() or "").lower()
expect(page.locator("#tune-note")).to_have_value("an anonymous attempt")
expect(page.locator("#tune-note")).to_have_value("a non-admin attempt")
# Nothing was created: the DB still holds only the seeded note.
with SessionLocal() as db:
+3 -2
View File
@@ -82,6 +82,7 @@ from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document
from e2e.auth_helpers import login
from tests.e2e.mock_llm import (
GREP_TEACH_MARKER,
GREP_TEACH_PATTERN,
@@ -373,7 +374,7 @@ def test_regex_grep_self_corrects_to_plain_form(
) -> None:
page.set_default_timeout(30_000)
_reset_db_fixture()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, GREP_TEACH_QUESTION)
@@ -434,7 +435,7 @@ def test_plain_search_flow_not_swallowed_by_new_trigger(
) -> None:
page.set_default_timeout(30_000)
_reset_db_fixture()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
# Turn 1 — the GREP-TEACH flow (the incident's regex-shaped first
+4 -3
View File
@@ -77,6 +77,7 @@ 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 tests.e2e.mock_llm import SEARCH_PATTERN, embed_text
REPO = Path(__file__).resolve().parents[2]
@@ -379,7 +380,7 @@ def test_read_flow_lines_answer_sources_no_raw_markup(
) -> None:
page.set_default_timeout(30_000)
_reset_db_read_pair()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, READ_QUESTION)
@@ -443,7 +444,7 @@ def test_grep_flow_line_then_answer(
_pin_fixture()
page.set_default_timeout(30_000)
_reset_db_search_fixture(mock_llm)
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, SEARCH_QUESTION)
@@ -488,7 +489,7 @@ def test_wire_argument_rule_across_both_flows(
_pin_fixture()
page.set_default_timeout(30_000)
_reset_db_read_pair()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
# Turn 1 — the READ flow (ls → read on the combined path).
+21 -4
View File
@@ -120,12 +120,16 @@ def test_header_height_identical_across_pages_desktop(
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
heights = _header_heights(page, app_url)
# Anonymous: chat + sources bars. Phase 79: the viewer's DATA is
# require_user-gated (its title row needs the content), so the
# document bar is measured signed-in, below — the bar height itself
# is auth-independent, which is exactly what both measurements pin.
page.goto(app_url + "/")
heights = {"chat": _box_height(page, ".app-header")}
page.goto(app_url + SOURCES_URL)
heights["sources"] = _box_height(page, ".app-header")
assert heights["chat"] == DESKTOP_HEADER_H, f"chat header {heights['chat']}px"
assert heights["sources"] == DESKTOP_HEADER_H, f"sources header {heights['sources']}px"
assert heights["document"] == DESKTOP_HEADER_H, (
f"document header {heights['document']}px (was content-sized)"
)
# Phase 16: the auth control lives in the same bar — anonymous sees
# "Sign in", signed-in sees "Sign out", and neither state moves the
@@ -139,6 +143,17 @@ def test_header_height_identical_across_pages_desktop(
expect(page.locator("#sign-in-link")).to_be_hidden()
assert _box_height(page, ".app-header") == DESKTOP_HEADER_H
# The document bar (row 1 — the standard bar of the two-row viewer
# header), signed in: identical to the other pages' bars.
page.goto(app_url + VIEWER_URL)
expect(page.locator("#doc-title")).to_have_text(
"Kubernetes Homelab Cluster", timeout=15_000
)
heights["document"] = _box_height(page, ".doc-header .app-header")
assert heights["document"] == DESKTOP_HEADER_H, (
f"document header {heights['document']}px (was content-sized)"
)
# ---------------------------------------------------------------------------
# 2. Mobile (≤640px): all three pages, one identical 58px bar
@@ -150,6 +165,7 @@ def test_header_height_identical_across_pages_mobile(
) -> None:
page.set_viewport_size({"width": 375, "height": 812})
_seed_db(mock_llm)
login(page, app_url, next="/") # phase 79: the viewer's title needs the content
heights = _header_heights(page, app_url)
assert heights["chat"] == MOBILE_HEADER_H, f"chat header {heights['chat']}px"
@@ -172,6 +188,7 @@ def test_viewer_header_content_still_fits(
survives in the titlebar ROW, and row 1 stays the pinned standard
bar — single-line on both desktop and mobile."""
_seed_db(mock_llm)
login(page, app_url, next="/") # phase 79: the viewer content is gated
for width, expected_h in ((1280, DESKTOP_HEADER_H), (375, MOBILE_HEADER_H)):
page.set_viewport_size({"width": width, "height": 800})
+3 -3
View File
@@ -376,7 +376,7 @@ def test_reload_after_hidden_tab_restores_one_bubble(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask_long(page)
answer = _wait_streaming(page)
@@ -416,7 +416,7 @@ def test_baseline_no_pagehide_still_completes(
an over-eager fix changing the ordinary settle)."""
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask_long(page)
answer = _wait_streaming(page)
@@ -470,7 +470,7 @@ def test_pre_token_guard_survives_hidden_window(
# 120 s guard among them) becomes test-controlled, while the mock's
# real-time stream is unaffected (the network is not a timer).
page.clock.install()
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
+11 -3
View File
@@ -29,6 +29,7 @@ from app.db import SessionLocal
from app.models import QueryLog
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import ADMIN_PASSWORD, login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -86,7 +87,7 @@ def test_off_topic_question_deflects_honestly(
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
page.fill("#message-input", OFF_TOPIC)
@@ -130,7 +131,7 @@ def test_deflection_suggestions_are_clickable(
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", OFF_TOPIC)
page.click("#send-btn")
chips = _deflected_chips(page)
@@ -162,8 +163,15 @@ def test_deflected_done_event_and_query_log(app_url: str, mock_llm: int, db_read
"""Raw SSE contract for a deflected turn + the durable query_log row."""
_reset_db(mock_llm, seed=True)
# Phase 79: POST /api/chat is require_user-gated — the httpx client
# signs in as the admin first (the form login's API side: 204 + the
# signed session cookie in the jar).
client = httpx.Client(timeout=60.0)
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204
frames: list[dict[str, Any]] = []
with httpx.stream(
with client.stream(
"POST", f"{app_url}/api/chat", json={"message": OFF_TOPIC}, timeout=60.0
) as r:
assert r.status_code == 200
+2 -1
View File
@@ -46,6 +46,7 @@ from app.models import Document, KbOverview, QueryLog
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from app.rag.overview import build_overview_prompt, regenerate_overview
from e2e.auth_helpers import login
from tests.e2e.mock_llm import TOKEN_RE
REPO = Path(__file__).resolve().parents[2]
@@ -128,7 +129,7 @@ def _ask(page: Page, app_url: str, question: str, done_text: str) -> Any:
marker (the no-row control, where the echo is absent by design).
"""
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", question)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble")
+4 -3
View File
@@ -47,6 +47,7 @@ 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"
@@ -149,7 +150,7 @@ def test_followup_receives_history_and_thinking(
# Cold start: no restored conversation — every prior turn the model
# sees on turn 2 is the one this test just sent.
page.add_init_script("localStorage.clear()")
page.goto(app_url)
login(page, app_url, next="/")
# Turn 1 — grounded + the thinking trigger: the brain record must
# carry the streamed scratchpad in its ``thinking`` key.
@@ -184,7 +185,7 @@ def test_first_question_has_no_history(
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.add_init_script("localStorage.clear()")
page.goto(app_url)
login(page, app_url, next="/")
# Cold start: the request body's history is empty — no phantom
# prior turns, no phantom thinking.
@@ -207,7 +208,7 @@ def test_deflected_followup_receives_history(
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.add_init_script("localStorage.clear()")
page.goto(app_url)
login(page, app_url, next="/")
_ask(page, T1)
brain1 = _wait_record(page, 2)["messages"][1]
+5 -4
View File
@@ -76,6 +76,7 @@ from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document, QueryLog
from e2e.auth_helpers import login
from tests.e2e.mock_llm import embed_text
REPO = Path(__file__).resolve().parents[2]
@@ -319,7 +320,7 @@ def test_dead_then_recovered_deflected(
) -> None:
_reset_db()
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, DEFLECT_Q)
@@ -370,7 +371,7 @@ def test_dead_then_recovered_grounded(
) -> None:
_reset_db()
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, GROUNDED_Q)
@@ -423,7 +424,7 @@ def test_embedding_retry_completes(
) -> None:
_reset_db()
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, EMBED_Q)
@@ -465,7 +466,7 @@ def test_exhaustion_lands_on_the_error_banner(
) -> None:
_reset_db()
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, EXHAUST_Q)
+6 -5
View File
@@ -40,6 +40,7 @@ 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"
@@ -179,7 +180,7 @@ def test_typing_indicator_during_slow_think(
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
@@ -212,7 +213,7 @@ def test_button_state_machine(
(class removed) + the input is focused back."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
@@ -241,7 +242,7 @@ def test_streaming_appends_live(
shorter than a later one (no 'whole answer appears at once')."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", ON_TOPIC)
page.click("#send-btn")
@@ -278,7 +279,7 @@ def test_reduced_motion_keeps_feedback(
_reset_db(mock_llm, seed=True)
page.emulate_media(reduced_motion="reduce")
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
@@ -302,7 +303,7 @@ def test_error_banner_on_llm_down(
with the actionable retry hint, and the button recovers (never a
zombie)."""
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", ON_TOPIC)
page.click("#send-btn")
+6 -1
View File
@@ -90,13 +90,18 @@ from app.db import SessionLocal
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]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_LOCALDIR", "8126"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
GIT_SOURCES_URL = "/git-sources.html"
+3 -2
View File
@@ -31,6 +31,7 @@ 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"
@@ -83,7 +84,7 @@ def test_long_answer_streams_to_completion(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(45_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
@@ -118,7 +119,7 @@ def test_normal_answer_unaffected(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", NORMAL_QUESTION)
page.click("#send-btn")
+2 -2
View File
@@ -121,7 +121,7 @@ def _ask_table_answer(page: Page, app_url: str) -> Any:
"""Drive the trigger question and return the brain bubble once the
whole byte-stable table answer has streamed in (the wide table's
last cell lands last)."""
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", QUESTION)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble").first
@@ -333,7 +333,7 @@ def test_plain_pipe_stays_text(
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", PLAIN_QUESTION)
page.click("#send-btn")
+37 -9
View File
@@ -113,6 +113,20 @@ def _open_menu(page: Page) -> None:
expect(page.locator("#app-nav")).to_have_css("opacity", "1")
def _js_open_menu(page: Page) -> None:
"""Phase 79 (task 05): the in-app token gate is a full-viewport
overlay for ANONYMOUS visitors — it physically covers the header,
so a real click on #nav-toggle is intercepted by the gate (the gate
is the only interactive surface; the header is locked out with the
rest of the page). The binding is identical, so the menu contract
is driven programmatically: a JS-dispatched click runs the exact
same listener a real click would."""
page.evaluate("() => document.querySelector('#nav-toggle').click()")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
expect(page.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b"))
expect(page.locator("#app-nav")).to_have_css("opacity", "1")
def _assert_menu_closed(page: Page) -> None:
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
assert "is-open" not in (page.locator("#app-nav").get_attribute("class") or ""), (
@@ -189,14 +203,19 @@ def test_anonymous_menu_contents(
visible link — "Chat". The three admin-only links keep their
ship-hidden state INSIDE the menu (the phase-19/35 contract is
preserved by reusing the same <nav> element); opening flips
aria-expanded true."""
aria-expanded true.
Phase 79 (task 05): the anonymous visitor meets the token gate — a
full-viewport overlay that covers the header — so the toggle is
driven programmatically (the binding is identical; see
_js_open_menu)."""
page = _mobile_page(browser)
try:
page.goto(app_url)
_wait_settled_anonymous(page)
_assert_menu_closed(page)
_open_menu(page)
_js_open_menu(page)
assert _visible_nav_links(page) == ["Chat"], (
"anonymous: the menu must show exactly one visible link (Chat)"
)
@@ -204,7 +223,7 @@ def test_anonymous_menu_contents(
expect(page.locator(sel)).to_be_hidden()
# A second click closes it again — aria-expanded round-trips.
page.click("#nav-toggle")
page.evaluate("() => document.querySelector('#nav-toggle').click()")
_assert_menu_closed(page)
finally:
page.close()
@@ -284,8 +303,11 @@ def test_esc_and_outside_close(
page.goto(app_url)
_wait_settled_anonymous(page)
# Phase 79 (task 05): the anonymous visitor's toggle click is
# intercepted by the gate overlay — drive the identical binding
# programmatically (see _js_open_menu).
# Esc closes + refocuses the opener.
_open_menu(page)
_js_open_menu(page)
page.keyboard.press("Escape")
_assert_menu_closed(page)
assert page.evaluate("() => document.activeElement.id") == "nav-toggle", (
@@ -294,8 +316,11 @@ def test_esc_and_outside_close(
# Outside click: the menu STAYS open (accepted behavior — the
# locked close set is Esc + link + resize, not backdrop click).
_open_menu(page)
page.locator("footer span").first.click() # a neutral, non-link point
# Phase 79 (task 05): for the anonymous visitor the "outside"
# point is the gate overlay itself — a REAL mouse click below
# the centered card (outside the nav, intercepted by the gate).
_js_open_menu(page)
page.mouse.click(10, 780) # the gate overlay — a neutral, non-nav point
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
assert "is-open" in (page.locator("#app-nav").get_attribute("class") or ""), (
"accepted behavior: an outside click must NOT close the menu"
@@ -339,8 +364,9 @@ def test_animation_and_reduced_motion(
f"got {report['property']!r}"
)
# Opening flips class + aria together (the animated state).
_open_menu(page)
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
# Phase 79 (task 05): the anonymous toggle click is intercepted
# by the gate overlay — programmatic drive, same binding.
_js_open_menu(page)
page.keyboard.press("Escape")
_assert_menu_closed(page)
finally:
@@ -363,7 +389,9 @@ def test_animation_and_reduced_motion(
assert _stilled("#app-nav") == "0s", (
f"reduced motion: closed state must not transition, got {_stilled('#app-nav')!r}"
)
rpage.click("#nav-toggle")
# Phase 79 (task 05): programmatic drive (the gate overlay
# intercepts the anonymous real click — same binding).
_js_open_menu(rpage)
expect(rpage.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
expect(rpage.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b"))
assert _stilled("#app-nav") == "0s", (
+12 -3
View File
@@ -233,9 +233,16 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
page.goto(app_url + url)
_wait_settled(page, admin=admin)
if name == "viewer":
# The document itself has settled (rendered, not Loading…/
# not-found) so the bar is measured on the real page.
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
# The viewer has settled, so the bar is measured on the real
# page. Phase 79 (task 05): content is require_user-gated —
# admin settles on the title; anonymous settles on the inline
# token gate (#doc-auth-gate — the content fetch never runs,
# the title stays "Loading…").
if admin:
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
else:
expect(page.locator("#doc-auth-gate")).to_be_visible(timeout=15_000)
expect(page.locator("#doc-title")).to_have_text("Loading…")
# The per-role VISIBLE inventory (the story: no button pops in or
# out because of which page you are on).
@@ -439,6 +446,7 @@ def test_viewer_row1_height_matches_chat_and_titlebar_present(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_seed_db(mock_llm)
login(page, app_url, next="/") # phase 79: the viewer's title needs the content
for width, expected_h in ((1280, DESKTOP_HEADER_H), (375, MOBILE_HEADER_H)):
page.set_viewport_size({"width": width, "height": 800})
@@ -474,6 +482,7 @@ def test_viewer_back_link_honors_back_param(
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
login(page, app_url, next="/") # phase 79: the viewer's title needs the content
# Positive: back=/ (same-origin relative) is honored — href "/",
# label "Chat", and the click returns to the chat page.
+9 -6
View File
@@ -112,11 +112,13 @@ CURRENT_LINK: dict[str, str | None] = {
#: The four primary nav links, in their physical DOM order — the labels
#: after the phase-48 swap (ids/hrefs unchanged).
NAV_LABELS = ("Chat", "RAG", "Sources", "Tuning")
#: The FIFTH a.nav-link in every page header (phase 53 saved-chat
#: history — ship-hidden, revealed by header.js for admins). The DOM
#: enumeration below therefore always sees it (pre-existing since phase
#: 53; the list below now matches the real nav).
NAV_TAIL = ("History",)
#: The FIFTH + SIXTH a.nav-link in every page header (phase 53
#: saved-chat history + phase 79 task 06 access tokens — both
#: ship-hidden, revealed by header.js for admins). The DOM enumeration
#: below therefore always sees them (pre-existing; the list matches the
#: real nav — the phase-34 one-bar contract ships the SAME nav, incl.
#: the Tokens link, on every page).
NAV_TAIL = ("History", "Tokens")
#: The login.js script — route pattern for the redirect suppression.
LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$")
@@ -197,7 +199,8 @@ def _assert_renamed_labels(page: Page, name: str) -> None:
expect(git).to_have_attribute("href", "/git-sources.html")
# The nav links (class nav-link) in physical DOM order — the four
# primaries plus the phase-53 admin-only History link.
# primaries plus the admin-only History (phase 53) and Tokens
# (phase 79 task 06) tail links.
nav_texts = page.eval_on_selector_all(
".app-nav a.nav-link", "els => els.map(e => e.textContent.trim())"
)
+1 -1
View File
@@ -515,7 +515,7 @@ def test_baseline_no_switch_still_completes(
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_ask_long(page)
answer = _wait_streaming(page, page.locator(ANSWER))
+629
View File
@@ -0,0 +1,629 @@
"""Phase 77 E2E (Playwright): navbar re-shows refresh the view's data —
the story suite for the phase-77 refresh hook + the History refresh
button.
Source: TODO.md L3 — "Clicking navbar icons should refresh the relevant
page. For example, clicking 'history' doesn't load new history until I
refresh. The history page should also have a refresh button."
Since the phase-76 shell, a view's data was fetched exactly once, at
mount (mount-once, hide-forever) — a History view opened at 10:00 still
showed 10:00's data at 10:30. Phase 77 makes every USER-INITIATED
re-show of an already-mounted view re-fetch its list: the router
dispatches ``bor:view-refresh`` on the view's section (a switch back
onto it, a re-click of its own nav link — NO pushState — or back/
forward onto it; the first show and boot never), the four data views
(History, RAG, Sources, Tuning) listen and re-run their loads, and the
History view gains an explicit Refresh button in its page-head (the
owner's second half of L3). The Chat view is deliberately excluded
(the in-flight stream and local conversation persist — the phase-76
contract; the unchanged ``test_nav_switch_keeps_stream.py`` suite is
the additional control).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_navbar_refresh.py -v --no-cov
The "freshness" proof (the house pattern from the phase overview):
create new backing data via the API AFTER a view has loaded, nav back
(or re-click / press Refresh), assert the new row — with the phase-76
canonical same-document sentinel (a ``window`` global set before the
nav clicks is still readable after — no document load).
Test → story mapping (Playwright Mapping Rule):
1. ``test_reshow_refetches_history`` — THE L3 REPRO: saved chat A
visible on History; chat B created via the API while Chat is up;
Chat → History: row B appears (replaced list — no duplicates), the
window sentinel survives every click (same document).
2. ``test_active_view_reclick_refetches`` — a re-click of the ACTIVE
view's own nav link re-fetches (today a no-op): chat C created via
the API while History is up; click History AGAIN → C appears, the
URL is still /history.html and ``history.length`` is unchanged (no
pushState).
3. ``test_history_refresh_button`` — the explicit Refresh control
(TODO.md L3's second half): chat D created via the API; click
#history-refresh → D appears, #history-status announces "Saved
chats refreshed.", the button is disabled while the request is in
flight (the house hold-the-request pattern — deterministic, not a
race) and re-enabled after (accessible name + keyboard focus
pinned too).
4. ``test_popstate_refetches_history`` — back/forward onto an
already-mounted view re-fetches: Chat → History (loads) → Chat →
create E via the API → ``page.go_back()`` (popstate) → History →
E present.
5. ``test_all_four_data_views_refetch_on_reshow`` — one assertion per
data view (History, RAG, Sources, Tuning): the GET to the view's
list endpoint is exactly +1 on a re-show (the request-log pattern
from task 02) — the first show (the mount) is the baseline.
6. ``test_stream_surval_control`` — the phase-76 CONTRACT control with
the hook in place: send a question (the mock LLM's ~8 s stream) →
mid-stream nav to RAG → back to Chat → the FULL answer completes
and the turn settles (one query_log row). The unchanged
``test_nav_switch_keeps_stream.py`` run in isolation is the
additional control.
"""
from __future__ import annotations
import asyncio
import re
import time
from collections.abc import Callable
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
from playwright.sync_api import Locator, Page, Request, 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.mock_llm import LONG_ANSWER_END, LONG_ANSWER_LINES
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
#: The phase-11 on-topic long-answer phrasing (house pattern,
#: test_nav_switch_keeps_stream.py): the honesty gate is HIGH and the
#: ~900-word answer streams for ~8–9 s — the guaranteed mid-stream
#: window.
LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer"
#: The typing indicator is itself a .msg.brain — exclude its bubble.
ANSWER = ".msg.brain .bubble:not(.typing)"
#: Distinct saved-chat titles per test (the DB is truncated at each
#: test's start, so fixed titles stay unambiguous within a test).
TITLE_A = "Phase77 re-show A"
TITLE_B = "Phase77 re-show B"
TITLE_C = "Phase77 re-click C"
TITLE_D = "Phase77 refresh button D"
TITLE_E = "Phase77 popstate E"
# The four data views (task 02's refresh joiners): the nav link, the
# view's URL (the pushState target), and the list endpoint the view's
# load hits (the request-log assertion's target — the sync/upload
# pollers hit OTHER paths and only run while a job is in flight).
DATA_VIEWS: tuple[tuple[str, str, str], ...] = (
("#nav-history", "/history.html", "/api/chats"),
("#nav-sources", "/sources.html", "/api/docs"),
("#nav-git-sources", "/git-sources.html", "/api/git-sources"),
("#nav-tuning", "/tuning.html", "/api/steering"),
)
# ---------------------------------------------------------------------------
# DB reset + API helpers (the house pattern from test_nav_switch_keeps_stream)
# ---------------------------------------------------------------------------
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) -> ImportSummary:
"""House reset + the prompt-shaping tables: steering notes and the
KB overview would otherwise append deterministic suffixes to every
mock answer and break the exact-text assertions; saved_chats is
cleared too (each test creates its own rows via the API)."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, "
"steering_notes, kb_overview, saved_chats"
)
)
db.commit()
return _run_in_thread(_import_fixtures(mock_port))
def _query_log_count() -> int:
"""The settled-row count over the whole (truncated) log — the
house pattern: a row finalizes ONLY when the LLM finished AND the
persistence succeeded, so 0 = cancelled, 1 = settled."""
with SessionLocal() as db:
return db.execute(text("SELECT count(*) FROM query_log")).scalar_one()
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login —
the test's API side sees exactly what the signed-in browser sees."""
return {c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c}
def _create_chat(app_url: str, cookies: dict[str, str], title: str) -> dict[str, Any]:
"""POST /api/chats (the public save surface) — the API-side
mutation the freshness proofs hinge on: the row is created while
the view is NOT showing, so only a re-fetch can surface it."""
r = httpx.post(
f"{app_url}/api/chats",
timeout=10,
cookies=cookies,
json={
"title": title,
"messages": [
{"who": "user", "text": f"Saved via the API for the phase-77 suite: {title}"},
{"who": "brain", "text": "Saved."},
],
},
)
assert r.status_code == 201, f"POST /api/chats for {title!r}: {r.status_code} {r.text}"
return r.json()
def _row(page: Page, title: str) -> Locator:
"""The saved chat's row (the Title cell's Open link text is the
title — textContent only, so an exact-text locator is stable)."""
return page.locator("#history-tbody a.history-title-link", has_text=title)
def _wait_until(page: Page, pred: Callable[[], bool], timeout: float = 15.0) -> None:
"""Poll ``pred`` until it holds — the tick is ``page.wait_for_timeout``
(NOT a raw ``time.sleep``): the sync API delivers ``page.on`` events
only while a Playwright call is in flight, so the request log the
predicate reads is drained on every tick (a raw sleep starves the
event queue)."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if pred():
return
page.wait_for_timeout(50)
raise AssertionError("timeout waiting for the condition")
def _get_logger(page: Page) -> list[str]:
"""Every GET the browser issues (the request-log pattern from task
02 — the re-fetch proof where a cheap row-delta is not available).
POST/PUT (auto-saves, form posts) are deliberately not logged: the
assertion is about the view's LIST fetch."""
seen: list[str] = []
def on_request(req: Request) -> None:
if req.method == "GET":
seen.append(req.url)
page.on("request", on_request)
return seen
# ---------------------------------------------------------------------------
# Shared stream helpers (mirrored from test_nav_switch_keeps_stream.py —
# the phase-76 suite must stay unchanged; this file is the story gate)
# ---------------------------------------------------------------------------
def _no_error_banner(page: Page) -> None:
expect(page.locator('[role="alert"]:visible')).to_have_count(0)
def _ask_long(page: Page) -> None:
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
def _wait_streaming(page: Page, answer: Locator) -> Locator:
"""Wait until answer text is visibly streaming (a few delta frames
rendered — the mid-stream moment, well inside the ~8–9 s stream)."""
answer.wait_for(state="visible", timeout=30_000)
partial = ""
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
partial = answer.inner_text()
if len(partial.split()) >= 8:
break
time.sleep(0.05)
assert len(partial.split()) >= 8, "no answer deltas before the view switch"
# In flight at the switch: the button IS the enabled Stop control.
expect(page.locator("#send-label")).to_have_text("Stop")
expect(page.locator("#send-btn")).to_have_class(re.compile(r"is-stop"))
return answer
def _wait_done(page: Page, answer: Locator) -> str:
"""Wait for the ``done`` settle: the Send button is back and the
bubble carries the unique final line — no error banner on the way."""
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
expect(page.locator("#send-btn")).not_to_have_class(re.compile(r"is-stop"))
expect(answer).to_contain_text(LONG_ANSWER_END, timeout=30_000)
_no_error_banner(page)
return answer.inner_text()
def _assert_full_answer(text: str) -> None:
"""The bubble carries the FULL mock answer — every one of the 40
numbered steps plus the unique final line (a truncated stream
would be missing its tail)."""
for i in range(1, LONG_ANSWER_LINES + 1):
assert f"Step {i}: configure node-{i}" in text, f"step {i} missing from the answer"
assert LONG_ANSWER_END in text
# ---------------------------------------------------------------------------
# 1. THE L3 REPRO: a switch back onto History re-fetches — the new row
# appears (list replaced, no duplicates), same document throughout
# ---------------------------------------------------------------------------
def test_reshow_refetches_history(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
"""Sign in → create saved chat A via POST /api/chats → nav to
History (row A visible) → create chat B via the API while Chat is
up → nav to Chat → nav back to History: row B is visible (the
re-show re-fetched — before phase 77 the 10:00 list stayed), row A
appears EXACTLY ONCE (the re-load replaces the list, never
duplicates it), and the window sentinel set before the nav clicks
is still readable after (the phase-76 canonical no-document-load
proof)."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page.locator("#nav-history")).to_be_visible()
cookies = _admin_cookies(page)
_create_chat(app_url, cookies, TITLE_A)
page.click("#nav-history")
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, TITLE_A)).to_be_visible(timeout=15_000)
# THE REPRO: B is created while History is NOT showing…
page.evaluate("() => { window.__shell_boot = 'phase77'; }")
_create_chat(app_url, cookies, TITLE_B)
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
# …and the switch back re-fetches: B is visible without a reload.
page.click("#nav-history")
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, TITLE_B)).to_be_visible(timeout=15_000)
# Replaced list, never duplicated: each title EXACTLY ONCE.
expect(_row(page, TITLE_A)).to_have_count(1)
expect(_row(page, TITLE_B)).to_have_count(1)
# Same document through every click (a real navigation would wipe
# the window sentinel).
assert page.evaluate("() => window.__shell_boot") == "phase77", (
"a real navigation would have wiped the window sentinel — "
"the switches must be same-document"
)
# ---------------------------------------------------------------------------
# 2. A re-click of the ACTIVE view's own nav link re-fetches (no-op
# before phase 77) — NO pushState (the URL is already the path)
# ---------------------------------------------------------------------------
def test_active_view_reclick_refetches(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""With History visible (rows A, B): create C via the API; click
the History nav link AGAIN (the active view's own link) → C
appears. The URL is still /history.html and history.length is
UNCHANGED — the re-click dispatches the refresh event instead of
a bare return, and it must not pushState (the URL already IS this
view's path)."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
cookies = _admin_cookies(page)
_create_chat(app_url, cookies, TITLE_A)
_create_chat(app_url, cookies, TITLE_B)
page.click("#nav-history")
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, TITLE_B)).to_be_visible(timeout=15_000)
length_before = page.evaluate("() => window.history.length")
_create_chat(app_url, cookies, TITLE_C)
page.click("#nav-history") # re-click the ACTIVE link
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, TITLE_C)).to_be_visible(timeout=15_000)
expect(_row(page, TITLE_A)).to_have_count(1)
length_after = page.evaluate("() => window.history.length")
assert length_after == length_before, (
"the active re-click must NOT pushState — the URL is already "
f"this view's path ({length_before} → {length_after})"
)
# ---------------------------------------------------------------------------
# 3. The explicit Refresh button (TODO.md L3's second half): the
# visible, keyboard-reachable control with its in-flight lifecycle
# ---------------------------------------------------------------------------
def test_history_refresh_button(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
"""The Refresh control (TODO.md L3: "The history page should also
have a refresh button."): visible for admin in the page-head,
keyboard-reachable (Tab-able, focusable, accessible name), and the
in-flight lifecycle — create D via the API; click #history-refresh
→ D appears, #history-status announces "Saved chats refreshed.",
the button is disabled DURING the in-flight request (asserted
deterministically: the request is held in the browser via
page.route — the house pattern from test_archive_upload_sources)
and re-enabled after."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
cookies = _admin_cookies(page)
_create_chat(app_url, cookies, TITLE_A)
page.click("#nav-history")
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, TITLE_A)).to_be_visible(timeout=15_000)
btn = page.locator("#history-refresh")
expect(btn).to_be_visible()
expect(btn).to_be_enabled()
# WCAG 2.1 AA basics: the accessible name (icon-only below 640px —
# the aria-label carries it in both) + keyboard-reachable.
assert btn.get_attribute("aria-label") == "Refresh saved chats"
btn.focus()
active = page.evaluate("() => document.activeElement && document.activeElement.id")
assert active == "history-refresh", "the Refresh button must be keyboard-focusable"
# Hold the refresh's GET in the browser so the in-flight state is
# observable deterministically instead of racing the fast
# localhost round-trip (the house pattern): the flag is armed only
# around the button's click.
hold = {"on": False}
def handle(route: Any) -> None:
if hold["on"]:
time.sleep(1.0)
route.continue_()
page.route("**/api/chats", handle)
_create_chat(app_url, cookies, TITLE_D)
hold["on"] = True
btn.click()
# In flight (§7.4): the button is disabled — no double-fire.
expect(btn).to_be_disabled()
hold["on"] = False
# Settled: D is visible (the re-fetch replaced the list), the live
# region carries the exact success line, the button is re-enabled.
expect(_row(page, TITLE_D)).to_be_visible(timeout=15_000)
expect(_row(page, TITLE_A)).to_have_count(1)
expect(page.locator("#history-status")).to_have_text("Saved chats refreshed.")
expect(btn).to_be_enabled()
# ---------------------------------------------------------------------------
# 4. Back/forward (popstate) onto an already-mounted view re-fetches
# ---------------------------------------------------------------------------
def test_popstate_refetches_history(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Chat → History (the mount's own load) → back to Chat → create E
via the API → ``page.go_back()`` (popstate onto the already-mounted
History view) → E is present: the popstate path flows through
switchTo, so it inherits the wasMounted gating — a re-show
dispatches bor:view-refresh, the first show never did (E was not
visible on the original show)."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
cookies = _admin_cookies(page)
page.click("#nav-history") # Chat → History (mount + first load)
expect(page).to_have_url(app_url + "/history.html")
expect(page.locator("#history-table-wrap")).to_be_visible(timeout=15_000)
page.click('a.nav-link[href="/"]') # → Chat (pushState)
expect(page).to_have_url(app_url + "/")
_create_chat(app_url, cookies, TITLE_E)
page.go_back() # popstate → /history.html (the already-mounted view)
expect(page).to_have_url(app_url + "/history.html")
expect(page.locator("#view-history")).not_to_be_hidden()
expect(_row(page, TITLE_E)).to_be_visible(timeout=15_000)
# ---------------------------------------------------------------------------
# 5. All four data views re-fetch on a re-show (the request-log pattern
# from task 02 — one assertion per view)
# ---------------------------------------------------------------------------
def test_all_four_data_views_refetch_on_reshow(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""One assertion per data view (History, RAG, Sources, Tuning):
after the first show (the mount — its load is the baseline), a
switch back onto the view issues EXACTLY ONE more GET to the
view's list endpoint — the re-fetch the phase is about. The
windowed counts (before/after the re-show click) make the
assertion immune to the boot-time fetches (the header's steering
panel loads once at shell boot) and to the sync/upload pollers
(other paths, and only while a job is in flight). For History the
row-delta rides along: a chat created before the re-show appears
after it."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible() # admin: every link revealed
expect(page.locator("#nav-history")).to_be_visible()
expect(page.locator("#nav-git-sources")).to_be_visible()
expect(page.locator("#nav-tuning")).to_be_visible()
cookies = _admin_cookies(page)
log = _get_logger(page)
def count(endpoint: str) -> int:
return sum(1 for u in log if u.endswith(endpoint))
for nav_sel, view_path, endpoint in DATA_VIEWS:
# First show (the mount) — wait until the mount's own load
# lands in the log (the baseline).
page.click(nav_sel)
expect(page).to_have_url(app_url + view_path)
_wait_until(page, lambda ep=endpoint: count(ep) >= 1)
before = count(endpoint)
# History: create the row-delta's backing data while hidden.
if endpoint == "/api/chats":
_create_chat(app_url, cookies, "Phase77 four-view row")
# Back to Chat, then a re-show of the view: the refresh event
# must issue exactly one more list GET.
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
page.click(nav_sel)
expect(page).to_have_url(app_url + view_path)
_wait_until(page, lambda b=before, ep=endpoint: count(ep) >= b + 1)
assert count(endpoint) == before + 1, (
f"{nav_sel}: a re-show must re-fetch exactly once "
f"({before} → {count(endpoint)} GETs to {endpoint})"
)
if endpoint == "/api/chats":
expect(_row(page, "Phase77 four-view row")).to_be_visible(timeout=15_000)
# ---------------------------------------------------------------------------
# 6. The phase-76 CONTRACT control: with the refresh hook in place, an
# in-flight stream still survives a mid-stream nav switch
# ---------------------------------------------------------------------------
def test_stream_survival_control(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
"""The phase-76 contract holds with the hook in place: send a
question (the mock LLM's ~8 s stream) → mid-stream nav to RAG (a
re-show-capable, LISTENING view — the hook is live) → back to Chat
→ the FULL answer completes and the turn settles (one query_log
row). The Chat view never listens for bor:view-refresh (the
negative unit pin) — its in-flight SSE reader and local
conversation persist through every switch. The unchanged
tests/e2e/test_nav_switch_keeps_stream.py run in isolation is the
additional control."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible()
_ask_long(page)
answer = _wait_streaming(page, page.locator(ANSWER))
# THE SWITCH (mid-stream): the window sentinel set BEFORE the click
# is still readable AFTER it — same document.
page.evaluate("() => { window.__shell_boot = 'phase77'; }")
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html")
assert page.evaluate("() => window.__shell_boot") == "phase77", (
"a real navigation would have wiped the window sentinel"
)
# The RAG view actually showed (the fixture docs' rows are listed)
# and the chat view is hidden (the stream fills it in the
# background — that persistence IS the phase-76 fix).
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#view-chat")).to_be_hidden()
# Stay on RAG while the stream keeps running, then return to Chat.
page.wait_for_timeout(2000)
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
assert page.evaluate("() => window.__shell_boot") == "phase77"
# The answer COMPLETED — the FULL mock answer, no error banner —
# and the turn SETTLED (one query_log row; a cancelled turn would
# leave none).
done_text = _wait_done(page, answer)
_assert_full_answer(done_text)
assert _query_log_count() == 1, "the completed turn must finalize exactly one settled row"
# ---------------------------------------------------------------------------
# 7. Sanity: the API-side rows the suite creates carry the bor.chat.v1
# record shape (guards _create_chat against a schema drift that
# would silently change what History renders)
# ---------------------------------------------------------------------------
def test_api_created_chats_carry_the_bor_chat_v1_shape(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""The suite's API-side mutation helper (POST /api/chats) produces
rows in the exact bor.chat.v1 record shape the History table and
the ?chat= restore path render: who/text messages, the message
count, and no stray keys (the schema's extra=forbid rejects them
at the boundary with a 422 — a 201 here proves the shape) — and
History renders the row (the Open link's text IS the title)."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
cookies = _admin_cookies(page)
created = _create_chat(app_url, cookies, "Phase77 shape check")
r = httpx.get(f"{app_url}/api/chats/{created['id']}", timeout=10, cookies=cookies)
assert r.status_code == 200
body = r.json()
assert body["title"] == "Phase77 shape check"
assert [m["who"] for m in body["messages"]] == ["user", "brain"]
assert body["message_count"] == 2
# The bor.chat.v1 record shape: who/text present; only the schema's
# optional keys may accompany them (explicit nulls are preserved —
# the plain model_dump round-trips byte-identical).
allowed = {"who", "text", "sources", "deflected", "suggestions",
"thinking", "tools", "stopped"}
assert all({"who", "text"} <= set(m) <= allowed for m in body["messages"])
# The History row renders it (the Open link's text IS the title).
page.click("#nav-history")
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, "Phase77 shape check")).to_be_visible(timeout=15_000)
+7 -6
View File
@@ -64,6 +64,7 @@ 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"
@@ -242,7 +243,7 @@ def test_no_autoscroll_during_long_answer(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# Turn 1 (settled) makes the document overflow the 800px viewport.
submit(page, LONG_QUESTION)
@@ -308,7 +309,7 @@ def test_no_autoscroll_during_thinking(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# One settled long turn so the document overflows (scrollable).
submit(page, LONG_QUESTION)
@@ -379,7 +380,7 @@ def test_submit_reveals_user_message(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# A populated conversation that overflows the viewport.
submit(page, LONG_QUESTION)
@@ -420,7 +421,7 @@ def test_restore_landing_one_shot(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# Settle a conversation (phase-14 persistence): long + short turns.
submit(page, LONG_QUESTION)
@@ -469,7 +470,7 @@ def test_restore_landing_one_shot(
def test_answer_content_intact(page: Page, app_url: str, seeded_kb: None) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# The long answer streams to completion with its sources ...
submit(page, LONG_QUESTION)
@@ -527,7 +528,7 @@ def test_submit_does_not_hop_up(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# A settled long answer (the document overflows the 800px viewport),
# and the user has scrolled to the very bottom to read it — the
+5 -4
View File
@@ -66,6 +66,7 @@ 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"
@@ -285,7 +286,7 @@ def test_composer_pinned_at_every_scroll_position(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
build_conversation(page, n=len(SHORT_QUESTIONS))
@@ -357,7 +358,7 @@ def test_stop_is_reachable_from_scrolled_up(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# Turn 1 (settled long answer) overflows the viewport — the user now
# has earlier content to read while turn 2 streams.
@@ -513,7 +514,7 @@ def test_empty_chat_composer_sits_at_the_screen_bottom(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# A fresh visitor: the empty state, nothing to scroll — and yet the
# input is already at the bottom of the screen.
@@ -555,7 +556,7 @@ def test_pin_holds_on_mobile_under_the_header(
page = browser.new_page(viewport=MOBILE)
try:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# An empty phone chat rests the input at the bottom of the screen.
expect(page.locator("#empty-state")).to_be_visible()
+1 -1
View File
@@ -188,7 +188,7 @@ def test_jinja_retrievable_not_deflected(
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", JINJA_QUESTION)
page.click("#send-btn")
+24 -6
View File
@@ -72,7 +72,6 @@ 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,
@@ -81,6 +80,13 @@ from e2e.conftest import (
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app (and the unconfigured one
# next to it, derived below) bind their own ports instead (a same-port
# second uvicorn dies on bind and would drive the wrong server).
# Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_DOCS", "8127"))
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
@@ -352,9 +358,17 @@ 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)."""
byte-identical for the deterministic mock (same KB, same question).
Phase 79: POST /api/chat is require_user-gated — the replay client
signs in as the admin first (every caller of this helper is an
admin-flow test; the guest flow never reaches it)."""
client = httpx.Client(timeout=120.0)
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204
frames: list[dict[str, Any]] = []
with httpx.stream(
with client.stream(
"POST", f"{app_url}/api/chat", json={"message": message}, timeout=120.0
) as r:
assert r.status_code == 200
@@ -567,10 +581,14 @@ def test_guest_has_no_button(
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.
# Phase 79 (task 05): the guest meets the token gate — the composer
# is inert behind it, so the guest can never send a turn at all, and
# the admin-only Save-as-doc affordance (which renders only on
# completed brain bubbles) can never appear on their surface.
expect(page.locator("#auth-gate")).to_be_visible(timeout=15_000)
assert page.evaluate("() => document.getElementById('main').inert") is True
expect(page.locator(".msg.brain .bubble")).to_have_count(0)
expect(page.locator(".save-as-doc-btn")).to_have_count(0)
# The draft API 403s anonymous callers (httpx, no cookie at all).
+62 -8
View File
@@ -206,13 +206,13 @@ def test_no_horizontal_overflow_at_viewports(
for width, height in VIEWPORTS:
page = browser.new_page(viewport={"width": width, "height": height})
try:
page.goto(f"{app_url}/")
login(page, app_url, next="/") # phase 79: the chips need a session
page.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
_assert_no_doc_overflow(page, f"chat @ {width}px")
login(page, app_url, next="/sources.html") # phase 16: admin-only
page.goto(f"{app_url}/sources.html") # phase 16: admin-only
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
_assert_no_doc_overflow(page, f"sources @ {width}px")
finally:
@@ -309,10 +309,62 @@ def test_a11y_landmarks_and_labels(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC3: landmarks, working skip link, labeled input, named controls,
and a visible :focus-visible outline on every keyboard focusable."""
and a visible :focus-visible outline on every keyboard focusable.
Phase 79 (task 05): the anonymous visitor meets the token gate —
the page body is inert behind it, and the gate's reveal contract
puts focus on the token input — so the skip-link / full-tab-walk
contract is checked for the SIGNED-IN admin (byte-identical to
before), and the gate gets its own focus contract: reveal focuses
the input, and input + submit both show the global 3px
:focus-visible outline."""
def _outline() -> dict[str, str]:
return page.evaluate(
"""() => {
const el = document.activeElement;
const cs = getComputedStyle(el);
return {id: el.id,
cls: String(el.className).split(" ")[0],
outline_style: cs.outlineStyle,
outline_width: cs.outlineWidth};
}"""
)
def _assert_outlined(info: dict[str, str], label: str, path: str) -> None:
width_px = float(info["outline_width"].replace("px", ""))
assert info["outline_style"] == "solid" and width_px >= 2, (
f"no visible focus outline on the gate {label} on {path} "
f"({info['outline_style']} {info['outline_width']})"
)
# --- Anonymous: the token gate's focus contract (phase 79) --------
for path in ("/", "/sources.html"):
page.goto(f"{app_url}{path}")
page.wait_for_load_state("networkidle")
expect(page.locator("#auth-gate")).to_be_visible(timeout=30_000)
# Reveal contract: focus lands on the token input — the first
# (and only) body control an anonymous visitor can act on.
info = _outline()
assert info["id"] == "auth-gate-input", (
f"gate reveal must focus the token input ({path}), on {info['id']!r}"
)
_assert_outlined(info, "input", path)
# Tab from the input lands on the gate submit, also outlined.
page.keyboard.press("Tab")
info = _outline()
assert info["cls"] == "auth-gate-submit", (
f"Tab from the gate input must land on the gate submit ({path}), "
f"on {info['id'] or info['cls']!r}"
)
_assert_outlined(info, "submit", path)
# --- Signed-in: the original AC3 contract (admin, gate hidden) ----
login(page, app_url)
for path in ("/", "/sources.html"):
page.goto(f"{app_url}{path}")
page.wait_for_load_state("networkidle")
expect(page.locator("#auth-gate")).to_be_hidden()
# Landmarks (PLAN §7.2).
assert page.locator("header.app-header").count() == 1, f"header missing on {path}"
@@ -380,8 +432,10 @@ def test_contrast_pairs_pass_aa(
) -> None:
"""AC4: every PLAN §7.2 color pair computed from the live computed
styles meets WCAG 2.1 AA (>= 4.5:1)."""
# Chat page: ink/surface, white/brand, chip-ink/chip-bg, deflection pair.
page.goto(f"{app_url}/")
# Chat page: ink/surface, white/brand, chip-ink/chip-bg, deflection
# pair. Phase 79: the chips need /api/suggestions (require_user-
# gated) — sign in first; the color pins are auth-independent.
login(page, app_url, next="/")
page.locator("#suggestions .suggestion-chip").first.wait_for(state="visible", timeout=10_000)
pairs = page.evaluate(
"""() => {
@@ -451,7 +505,7 @@ def test_reduced_motion_respected(
# Control: default context — the dots run the `typing` animation.
page = browser.new_page(viewport={"width": 1280, "height": 800})
try:
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
expect(page.locator(TYPING)).to_be_visible(timeout=1_000)
@@ -470,7 +524,7 @@ def test_reduced_motion_respected(
)
rpage = context.new_page()
try:
rpage.goto(app_url)
login(rpage, app_url, next="/") # phase 79: chat is require_user-gated
rpage.fill("#message-input", SLOW_QUESTION)
rpage.click("#send-btn")
expect(rpage.locator(TYPING)).to_be_visible(timeout=1_000)
@@ -538,7 +592,7 @@ def test_long_content_wraps_without_overflow(
# Chat @ 375px: unbroken 60-char tokens wrap inside both bubbles.
chat = browser.new_page(viewport={"width": 375, "height": 812})
try:
chat.goto(app_url)
login(chat, app_url, next="/") # phase 79: chat is require_user-gated
chat.set_default_timeout(30_000)
chat.fill("#message-input", f"what do the notes say about {'x' * 60}")
chat.click("#send-btn")
+3 -3
View File
@@ -129,7 +129,7 @@ def test_gitlab_question_is_grounded_with_gitlab_chip(
must land on the tool's own document — not a generic template."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask(page, GITLAB_QUESTION)
expect(page.locator(".msg.user .bubble")).to_contain_text(GITLAB_QUESTION)
@@ -160,7 +160,7 @@ def test_keyword_only_question_beats_vector_ranking(
weak — the lexical branch is what grounds the answer."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask(page, KEYWORD_QUESTION)
bubble = page.locator(".msg.brain .bubble").first
@@ -190,7 +190,7 @@ def test_off_topic_still_deflects_with_chips(
"sourdough" matches nothing in the KB lexically → honest deflection."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask(page, OFF_TOPIC)
bubble = page.locator(".msg.brain.is-deflected .bubble").first
+5 -4
View File
@@ -53,6 +53,7 @@ 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"
@@ -221,7 +222,7 @@ def test_retry_redoes_in_place(
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# Two grounded turns: the conversation is [u1, b1, u2, b2].
_ask(page, Q1)
@@ -290,7 +291,7 @@ def test_retry_on_stopped_partial(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_stop_long_answer_mid_stream(page)
@@ -344,7 +345,7 @@ def test_retry_deflected(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_ask_deflected(page, OFF_TOPIC)
@@ -401,7 +402,7 @@ def test_retry_inert_while_in_flight(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# One completed turn: the last (only) brain bubble carries Retry.
_ask(page, Q1)
+14 -3
View File
@@ -73,7 +73,6 @@ 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,
@@ -82,6 +81,11 @@ from e2e.conftest import (
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_SAVEDOC", "8129"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
BRANCH = "bor-docs"
@@ -342,9 +346,16 @@ def _stream_chat_answer(app_url: str, message: str) -> str:
answer is a pure function of the LAST user message + the document
context (both identical whether or not the browser's phase-74
history rode along), so a bare replay recovers the same bytes the
multi-turn browser session rendered."""
multi-turn browser session rendered.
Phase 79: POST /api/chat is require_user-gated — the replay client
signs in as the admin first (the caller is an admin-flow test)."""
client = httpx.Client(timeout=120.0)
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204
frames: list[dict[str, Any]] = []
with httpx.stream(
with client.stream(
"POST", f"{app_url}/api/chat", json={"message": message}, timeout=120.0
) as r:
assert r.status_code == 200
+20 -11
View File
@@ -12,10 +12,14 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_save_share_ux.py -v --no-cov
The owner-locked loop under test (2026-08-31):
The owner-locked loop under test (2026-08-31). Phase 79 note: chat is
now ``require_user``-gated (only shared chats stay open), so the
"signed-out visitor" of the L3/L4 pins signs in first — the mechanism
pins (no Save control, silent auto-save, exactly one row, share toast,
unshare, layout) are unchanged.
* **Anonymous auto-save (L4 / A2)** — there is NO Save control in the
DOM at any width; a signed-out visitor's first question auto-upserts
* **Auto-save (L4 / A2)** — there is NO Save control in the
DOM at any width; the visitor's first question auto-upserts
EXACTLY ONE ``saved_chats`` row (the auto-title, both messages) —
verified through the admin's ``GET /api/chats`` (the management
surface stays admin-only, phase 55 task 01) with no button press
@@ -253,9 +257,11 @@ def test_anonymous_auto_save(
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
# Anonymous by construction: the page fixture's context never logs
# in — this is the signed-out visitor.
page.goto(app_url + "/")
# Phase 79: chat is require_user-gated — the phase-55 "signed-out
# visitor" no longer exists (shared chats are the only open
# surface), so the default visitor signs in first; every other pin
# (no Save control, silent auto-save, exactly one row) is unchanged.
login(page, app_url, next="/")
# L4: there is NO Save control at any width — the element is gone
# from the DOM (phase 55, task 02), the pills are New chat + Share.
@@ -300,7 +306,7 @@ def test_no_duplicate_row_across_reload(
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url + "/")
login(page, app_url, next="/") # phase 79: chat is require_user-gated
q1 = "How is my Kubernetes cluster set up? (save-ux-reload)"
_ask(page, q1)
@@ -350,11 +356,14 @@ def test_anonymous_share_toast_and_unshare(
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
# Anonymous by construction — no login anywhere in this test.
page.goto(app_url + "/")
# Phase 79: the phase-55 "no login anywhere" visitor no longer
# exists (chat is gated) — the visitor signs in first; the FRESH
# incognito context below (the read-only shared view) stays
# anonymous, which is the open surface this story pins.
login(page, app_url, next="/")
# L3: the Share pill is visible WITHOUT login (static markup — the
# phase-51 admin-only reveal gate is gone, task 03).
# L3: the Share pill is visible (static markup — the phase-51
# admin-only reveal gate is gone, task 03).
share = page.locator("#share-chat-btn")
expect(share).to_be_visible()
expect(share).to_have_attribute("aria-label", "Share chat")
+4 -3
View File
@@ -75,6 +75,7 @@ from app.db import SessionLocal
from app.models import QueryLog
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
from tests.e2e.mock_llm import SEARCH_PATTERN, SEARCH_TRIGGER
REPO = Path(__file__).resolve().parents[2]
@@ -337,7 +338,7 @@ def test_search_flow_searches_and_answers_from_match(
_pin_fixture()
page.set_default_timeout(30_000)
_reset_db(mock_llm)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, SEARCH_QUESTION)
@@ -415,7 +416,7 @@ def test_search_adds_no_source_by_itself(
_pin_fixture()
page.set_default_timeout(30_000)
_reset_db(mock_llm)
page.goto(app_url)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, SEARCH_QUESTION)
@@ -457,7 +458,7 @@ def test_search_tool_line_re_renders_after_reload(
_pin_fixture()
page.set_default_timeout(30_000)
_reset_db(mock_llm)
page.goto(app_url)
login(page, app_url, next="/")
_submit(page, SEARCH_QUESTION)
_wait_settled(page)
+11 -3
View File
@@ -194,9 +194,17 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str, mobile: bool = Fa
expect(nav).to_be_hidden()
if page_kind == "viewer":
# The document itself has settled (rendered, not Loading…/not-found)
# so the bar is being measured on the real page.
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
# The viewer has settled, so the bar is measured on the real
# page. Phase 79 (task 05): the content endpoint is
# require_user-gated — admin settles on the document title;
# anonymous settles on the inline token gate (#doc-auth-gate —
# the phase-16 not-found-card surface is replaced by the gate:
# the content fetch never runs, the title stays "Loading…").
if admin:
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
else:
expect(page.locator("#doc-auth-gate")).to_be_visible(timeout=15_000)
expect(page.locator("#doc-title")).to_have_text("Loading…")
# The bar height never moves: 64px desktop / 58px ≤640px (phase 12),
# bounding-box measurement — the new pills must fit inside it.
+7 -1
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
import httpx
from playwright.sync_api import Page, expect
from e2e.auth_helpers import login
def test_health_endpoint(app_url: str) -> None:
r = httpx.get(f"{app_url}/api/health", timeout=5)
@@ -26,7 +28,11 @@ def test_index_page_loads_locally(page: Page, app_url: str) -> None:
def test_chat_roundtrip_never_stale_button(page: Page, app_url: str) -> None:
page.goto(app_url)
# Phase 79: chat is require_user-gated (and the in-app token gate
# locks #main for anonymous) — the round-trip signs in as admin
# first (real form login; DB-free, so the story's DB-down error-
# banner path still works).
login(page, app_url, next="/")
page.locator("#message-input").fill("hello brain")
page.locator("#send-btn").click()
+6 -1
View File
@@ -103,13 +103,18 @@ from app.models import Document, GitSource
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]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_REMOVAL", "8131"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
GIT_SOURCES_URL = "/git-sources.html"
+3 -3
View File
@@ -424,7 +424,7 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", HESITATE_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
@@ -482,7 +482,7 @@ def test_completed_turn_unaffected(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask(page, QUESTION)
# The done save point: full answer + metadata, exactly as phase 14.
@@ -526,7 +526,7 @@ def test_new_chat_still_clears_conversation(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask(page, QUESTION)
assert _stored(page) is not None
+4 -2
View File
@@ -25,6 +25,8 @@ from __future__ import annotations
from playwright.sync_api import Page, expect
from e2e.auth_helpers import login
# The locked replacement copy (owner-locked A1 / A2 / A3 — same literals
# as tests/unit/test_stale_ui_copy.py; the unit pins guard the files,
# this suite guards what the visitor actually sees).
@@ -64,7 +66,7 @@ def test_chat_page_shows_no_homelab_or_deployment_text(
rendered suggestion chips are covered by this body scan (case-
insensitive; a fresh page context has no saved chats, so the empty
state is what renders)."""
page.goto(f"{app_url}/")
login(page, app_url, next="/")
expect(page.locator("#suggestions .suggestion-chip")).to_have_count(len(CHIPS))
body = page.evaluate("() => document.body.innerText.toLowerCase()")
assert "homelab" not in body, f"homelab-era text rendered on the chat page: {body!r}"
@@ -79,7 +81,7 @@ def test_chat_page_suggestion_chips_are_the_locked_list(
"""The four rendered empty-state chips (from ``GET
/api/suggestions``, the code default) are the locked (A2) list, in
order."""
page.goto(f"{app_url}/")
login(page, app_url, next="/")
chips = page.locator("#suggestions .suggestion-chip")
expect(chips).to_have_count(len(CHIPS))
for i, chip in enumerate(CHIPS):
+4 -3
View File
@@ -48,10 +48,11 @@ NOTE = "STEEER-MARKER be concise"
XSS_NOTE = "<script>window.__xss = true; alert('xss')</script>"
#: The shell (index.html — served for BOTH / and /tuning.html since
#: phase 76 task 01, when the Tuning view folded into it) ships exactly
#: FOUR classic/module script tags: the phase-39 brand.js classic layer
#: FIVE classic/module script tags: the phase-39 brand.js classic layer
#: + markdown.js + the chat module (app.js) + the shell router
#: (router.js, which lazy-imports the tuning.js view module).
BASE_SCRIPT_COUNT = 4
#: (router.js, which lazy-imports the tuning.js view module) + the
#: phase-79 token gate (token-gate.js, phase 79 task 05).
BASE_SCRIPT_COUNT = 5
async def _import_fixtures(mock_port: int) -> ImportSummary:
+1
View File
@@ -252,6 +252,7 @@ def test_doc_header_stuck_at_top_on_long_document(
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``.
login(page, app_url, next="/") # phase 79: the viewer content is gated
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()
+4 -3
View File
@@ -47,6 +47,7 @@ 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"
@@ -226,7 +227,7 @@ def test_stop_mid_stream(
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
stopped_text = _stop_mid_stream(page)
@@ -266,7 +267,7 @@ def test_stop_pre_token(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
_stop_pre_token(page, SLOW_QUESTION, via_enter=False)
@@ -302,7 +303,7 @@ def test_stopped_turn_survives_reload(
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
stopped_text = _stop_mid_stream(page)
assert _query_log_count() == 0
+265 -32
View File
@@ -1,37 +1,110 @@
"""Phase 05 E2E (Playwright): onboarding suggestion chips, one-tap submit.
"""Phase 80 E2E (Playwright): onboarding chips = the last 3 questions asked.
Story: ``.agents/user_stories/suggestion-chips.md`` (phase 05) —
REWRITTEN in place for the phase-80 semantics (the phase-76 precedent:
a semantic change rewrites the story suite in place). Source:
``TODO.md`` L6.
The new contract (owner decision A6): the empty-state chip row is the
3 most recent user questions across ALL saved chats — chats walked
newest-``updated_at`` first, each chat's messages newest-first,
exact (case-sensitive) de-duplicated, cap 3. A fresh deployment — zero
saved questions — gets the SEED list instead (``BOR_SUGGESTIONS`` /
the built-in default). 1–2 saved questions → exactly those chips (NO
mixing with the seed). The row refetches when the empty state comes
back (New chat), so it is never stale. The deflection "Maybe try"
chips are a separate contract (``derive_suggestions``) — untouched.
The four states pinned here:
* **seed** — fresh DB (no saved chats) → the chip texts equal the
built-in default list EXACTLY (the ``SEED`` literal below is the
pin for the exact seed list — ``tests/unit/test_config.py`` pins
only the shape) — rendered as accessible buttons in the role=list
group, exactly as the phase-05 component contract;
* **last-3** — two saved chats with 5 user questions total (the older
one saved FIRST — the API stamps ``updated_at``) → a fresh page
load shows EXACTLY the 3 newest questions, newest-first;
* **partial** — exactly 2 saved questions deployment-wide → exactly
2 chips (no seed top-up — the A6 contract, visible in the UI);
* **refetch** — boot with the seed chips, save a chat whose newest
question is Q via the API, click New chat (``#new-chat-btn``) → the
chips now are Q, and the request log shows a SECOND
``GET /api/suggestions`` (the boot fetch was the first).
Carried-over story behavior (unchanged semantics from the phase-05
suite): one-tap submit (chip click → composer filled → submitted →
the mock-LLM brain bubble), Tab+Enter keyboard reachability of the
chips (the keyboard-walk assertion), and the mobile single
horizontal-scroll row.
The endpoint is authed (phase 79, ``require_user``), so every test
signs in as admin first (``auth_helpers.login``). ``saved_chats`` is
global state on the shared e2e Postgres AND the state this contract
reads — the autouse fixture truncates it before and after EVERY test
(including the ones whose turns auto-save a row), so each test starts
from — and leaves — an empty deployment.
Story: ``.agents/user_stories/suggestion-chips.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov
The onboarding row in the empty state renders real ``<button>`` chips from
``GET /api/suggestions`` (settings defaults). Clicking — or Tab + Enter —
fills the composer AND submits: one tap produces a user bubble with the
chip's exact text and a streamed Brain reply. On mobile (375px) the row
becomes a single horizontally scrollable line.
"""
from __future__ import annotations
import asyncio
import json
import time
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
import pytest
from playwright.sync_api import Browser, Page, expect
from playwright.sync_api import Browser, Page, Request, 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"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: The EXACT built-in onboarding SEED (phase 80, TODO.md L6): the chip
#: row of a brand-new deployment, shown only before any question has
#: ever been saved. This literal is the E2E pin for the exact seed
#: list — ``tests/unit/test_config.py`` pins only the SHAPE (>=3
#: non-blank distinct strings), and the e2e app under test is forced
#: to the code default by conftest's leak guard — keep in sync with
#: the ``Settings.suggestions`` default in ``app/config.py``.
SEED: list[str] = [
"What documents are in the knowledge base?",
"Which source does each answer come from?",
"How do I add a new source?",
"Summarize the most recent document.",
]
@pytest.fixture(autouse=True)
def clean_chats(db_ready: None) -> Iterator[None]:
"""``saved_chats`` is the state the phase-80 contract reads:
truncate it before and after every test so each state test starts
from (and leaves) an empty deployment. Unlike the KB tables, this
reset is non-optional — the chips ARE these rows, and the
carried-over submit tests auto-save a row per turn, which would
otherwise leak into the later state tests."""
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
yield
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
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"}
@@ -58,7 +131,9 @@ def _run_in_thread(coro: Any) -> Any:
def _seed_kb(mock_port: int) -> ImportSummary:
"""Deterministic KB: truncate everything, import the fixture docs."""
"""Deterministic KB: truncate the KB tables, import the fixture
docs (needed by the carried-over submit tests' grounded answers).
``saved_chats`` is the autouse fixture's job."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
@@ -67,42 +142,75 @@ def _seed_kb(mock_port: int) -> ImportSummary:
return summary
def _api_suggestions(app_url: str) -> list[str]:
body = httpx.get(f"{app_url}/api/suggestions", timeout=10).json()
return body["suggestions"]
def _user(q: str) -> dict[str, Any]:
return {"who": "user", "text": q}
def _brain(text: str = "Grounded mock brain reply.") -> dict[str, Any]:
return {"who": "brain", "text": text}
def _save_chat(page: Page, app_url: str, messages: list[dict[str, Any]]) -> dict[str, Any]:
"""Save one conversation as the signed-in admin (``POST /api/chats``)
and return the 201 body. ``updated_at`` is the API's stamp (server
``now()`` at INSERT) — the save ORDER is what makes the chip-walk
order deterministic in the state tests."""
r = page.request.post(
f"{app_url}/api/chats",
data=json.dumps({"messages": messages}),
headers={"Content-Type": "application/json"},
timeout=10_000,
)
assert r.status == 201, r.text
return r.json()
def _api_suggestions(page: Page, app_url: str) -> list[str]:
# Phase 79: the endpoint is require_user-gated — the request rides
# the page's signed-in context (each test signs in above).
r = page.request.get(f"{app_url}/api/suggestions", timeout=10)
assert r.status == 200, r.text
return r.json()["suggestions"]
def _chip_locator(page: Page) -> Any:
return page.locator("#suggestions .suggestion-chip")
def test_onboarding_chips_render(
page: Page, app_url: str, mock_llm: int, db_ready: None
def _chip_texts(page: Page) -> list[str]:
return [c.strip() for c in _chip_locator(page).all_inner_texts()]
def test_seed_state_chips_are_the_builtin_default(
page: Page, app_url: str, db_ready: None
) -> None:
_seed_kb(mock_llm)
"""Fresh deployment (no saved chats) → the chip row is EXACTLY the
built-in seed list — texts, count, and order — rendered in
``#suggestions`` (role="list") as accessible buttons, exactly as
the phase-05 component contract."""
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
# Accessible group: role=list + a name screen readers can announce.
group = page.locator("#suggestions")
expect(group).to_have_attribute("role", "list")
expect(group).to_have_attribute("aria-label", "Suggested questions")
# 3+ visible chips, real buttons, each with non-empty text — and the
# texts match what the API returned (chips are drawn from the endpoint).
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
assert chips.count() >= 3
api_texts = _api_suggestions(app_url)
# The EXACT seed list, in order (the E2E pin — see the SEED literal).
assert _chip_texts(page) == SEED
# ...drawn from the endpoint (the API returns the same exact list).
assert _api_suggestions(page, app_url) == SEED
# Real buttons, each with non-empty text, one per seed entry.
assert chips.count() == len(SEED)
for i in range(chips.count()):
chip = chips.nth(i)
expect(chip).to_be_visible()
expect(chip).to_have_attribute("type", "button")
expect(chip).to_have_attribute("role", "listitem")
text = chip.inner_text().strip()
assert text, "every chip needs non-empty label text"
assert text in api_texts
assert len(set(api_texts)) >= 3
assert chip.inner_text().strip(), "every chip needs non-empty label text"
# Chips live in the empty state, which is visible before any message.
expect(page.locator("#empty-state")).to_be_visible()
@@ -115,17 +223,136 @@ def test_onboarding_chips_render(
assert box is not None and box["height"] >= 44
def test_last_three_questions_state(page: Page, app_url: str, db_ready: None) -> None:
"""5 user questions across two saved chats (the older one saved
FIRST — the API stamps ``updated_at`` at INSERT) → a fresh page
load shows EXACTLY the 3 newest questions, newest-first: the
newer chat is walked first, then the older chat newest-first."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
old_q = [
"How did I install the GitLab runner on the Proxmox node?",
"Which disk holds the Borg backup archives?",
"How is the nftables firewall rule set ordered?",
]
new_q = [
"What TLS termination does Traefik do for homelab.local?",
"Which provider is the primary DNS for reeseapps.com?",
]
# The OLDER chat first: the API stamps ``updated_at`` (server
# now()), so save order IS walk order. The short pause keeps the
# two stamps strictly apart (and the assert below pins that the
# order the walk sees is the order the test intended).
older = _save_chat(
page,
app_url,
[
_user(old_q[0]), _brain(),
_user(old_q[1]), _brain(),
_user(old_q[2]), _brain(),
],
)
time.sleep(0.05)
newer = _save_chat(
page,
app_url,
[
_user(new_q[0]), _brain(),
_user(new_q[1]), _brain(),
],
)
assert datetime.fromisoformat(newer["updated_at"]) > datetime.fromisoformat(
older["updated_at"]
), "the two API-stamped updated_at values must be strictly ordered"
# A FRESH page load (a new boot fetch, not the pre-save boot):
# the chips are exactly the 3 newest questions, newest first.
page.goto(app_url + "/")
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
expected = [new_q[1], new_q[0], old_q[2]]
assert _chip_texts(page) == expected
assert _api_suggestions(page, app_url) == expected
# The two older questions (and everything seed-shaped) are gone.
assert old_q[0] not in _chip_texts(page)
assert old_q[1] not in _chip_texts(page)
def test_partial_state_no_seed_topup(page: Page, app_url: str, db_ready: None) -> None:
"""Exactly 2 saved questions deployment-wide → EXACTLY 2 chips
(newest first) — NO mixing/top-up with the seed (the A6
contract, visible in the UI)."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
a = "How do I rotate the WireGuard keys on the VPN node?"
b = "What cron schedule runs the restic prune?"
_save_chat(page, app_url, [_user(a), _brain()])
time.sleep(0.05)
_save_chat(page, app_url, [_user(b), _brain()])
page.goto(app_url + "/")
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
assert chips.count() == 2, "exactly 2 chips — the row is never padded toward 3"
texts = _chip_texts(page)
assert texts == [b, a]
assert not (set(texts) & set(SEED)), "no seed text may appear once a question is saved"
def test_new_chat_refetches_the_chips(page: Page, app_url: str, db_ready: None) -> None:
"""The row is never stale: boot with the seed chips → save a chat
whose newest question is Q via the API → click New chat
(``#new-chat-btn``) → the empty state comes back with the
REFETCHED row (exactly Q — the deployment now has one saved
question), and the request log shows a SECOND
``GET /api/suggestions`` (the boot fetch was the first)."""
page.set_default_timeout(30_000)
sugg_gets: list[float] = []
def on_request(req: Request) -> None:
if req.url.endswith("/api/suggestions"):
sugg_gets.append(time.monotonic())
page.on("request", on_request)
login(page, app_url, next="/")
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
assert _chip_texts(page) == SEED, "boot state: the seed row"
assert len(sugg_gets) == 1, "exactly one GET /api/suggestions at boot"
q = "Which service fronts the Pi-hole DNS on the network?"
_save_chat(page, app_url, [_user(q), _brain()])
clicked_at = time.monotonic()
page.click("#new-chat-btn")
# The refetch re-renders #suggestions in place: the 4 seed chips
# are replaced by exactly Q (the partial state, live).
expect(chips).to_have_count(1, timeout=15_000)
expect(chips.first).to_have_text(q, timeout=15_000)
assert len(sugg_gets) == 2, "New chat triggered the refetch"
assert sugg_gets[1] > clicked_at, "the second GET is AFTER the click — the refetch"
def test_chip_click_submits(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Carried over (phase 05, unchanged semantics): one tap = one
question — the click fills AND submits; a grounded mock reply
follows. The chip submitted is the SEED row's first entry (the
autouse fixture guarantees the seed state)."""
_seed_kb(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
first = _chip_locator(page).first
expect(first).to_be_visible(timeout=30_000)
chip_text = first.inner_text().strip()
assert chip_text
assert chip_text == SEED[0]
# One tap = one question: the click fills AND submits.
first.click()
@@ -148,9 +375,12 @@ def test_chip_click_submits(
def test_chips_keyboard_accessible(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Carried over (phase 05, unchanged semantics): the first chip is
keyboard-reachable BEFORE the composer input (skip-link + nav
links come first), and Enter activates it — submitting."""
_seed_kb(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
first = _chip_locator(page).first
expect(first).to_be_visible(timeout=30_000)
chip_text = first.inner_text().strip()
@@ -193,13 +423,16 @@ def test_chips_keyboard_accessible(
def test_chips_mobile_row(
browser: Browser, app_url: str, mock_llm: int, db_ready: None
browser: Browser, app_url: str, db_ready: None
) -> None:
_seed_kb(mock_llm)
"""Carried over (phase 05, unchanged semantics): on mobile (375px)
the row is a single horizontally scrollable line — the seed state
(4 chips) overflows into scroll, nothing wraps, chips stay
>=44px tall on one line."""
page = browser.new_page(viewport={"width": 375, "height": 720})
try:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
row = page.locator("#suggestions")
expect(row).to_be_visible(timeout=30_000)
+19 -10
View File
@@ -24,8 +24,11 @@ This phase only surfaces the stored field: the content endpoint returns
``summary`` (task 01) and the shared ``renderDocument`` core draws the
labeled ``.doc-summary`` panel above the content on BOTH surfaces (task
02) — the full-page viewer and the chat/sources modal. The tests assert
exactly that contract, plus the phase-16 soft rule: the content
endpoint stays public (anonymous fetch → 200, no admin cookie needed).
exactly that contract. Phase 79 supersedes the phase-16 soft rule: the
content endpoint is ``require_user``-gated, so the viewer surfaces and
the API shape pin run under a signed-in session (the shape itself —
``summary`` for the yaml, ``null`` for the markdown control — is
unchanged).
"""
from __future__ import annotations
@@ -140,6 +143,7 @@ def test_full_page_shows_summary_and_original_together(
cannot contain, is rendered in the raw ``<pre>``."""
_reset_db_and_import(mock_llm)
page.set_default_timeout(30_000)
login(page, app_url, next="/") # phase 79: the viewer content is gated
digest_line, pointer_line = _summary_lines(SOURCE, YAML_PATH)
page.goto(f"{app_url}/document.html?source={SOURCE}&path={YAML_URL_PATH}")
@@ -279,6 +283,7 @@ def test_markdown_doc_has_no_summary_panel(
before (first child of the content container is the doc body)."""
_reset_db_and_import(mock_llm)
page.set_default_timeout(30_000)
login(page, app_url, next="/") # phase 79: the viewer content is gated
# Full page: no panel, markdown column untouched.
page.goto(f"{app_url}/document.html?source={SOURCE}&path={MD_URL_PATH}")
@@ -294,7 +299,8 @@ def test_markdown_doc_has_no_summary_panel(
assert order == ["doc-md"], f"markdown doc gained children: {order}"
# Modal: same story — no panel, .doc-md is the sole content child.
login(page, app_url)
# (the session is already signed in — the form login above)
page.goto(f"{app_url}/sources.html")
row = page.locator("#docs-tbody tr", has_text=MD_PATH)
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
@@ -312,18 +318,21 @@ def test_markdown_doc_has_no_summary_panel(
# ---------------------------------------------------------------------------
# 4. API shape (cheap, via the page context's request — still anonymous)
# 4. API shape (cheap, via the page context's request — signed in since
# phase 79 gated the endpoint)
# ---------------------------------------------------------------------------
def test_content_api_summary_shape_anonymous(
def test_content_api_summary_shape(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""``GET /api/documents/content`` carries ``summary`` — the string
for the summarized yaml, ``null`` for the markdown control — and
stays PUBLIC: no admin cookie is set anywhere in this test, so both
200s prove the phase-16 soft rule (viewer public) is unchanged."""
for the summarized yaml, ``null`` for the markdown control. Phase 79
superseded the phase-16 soft rule: the endpoint is require_user, so
the pin runs under the form-login session (the shape itself is
unchanged)."""
_reset_db_and_import(mock_llm)
login(page, app_url, next="/")
digest_line, _ = _summary_lines(SOURCE, YAML_PATH)
expected_summary = f"{digest_line}\nSource: {SOURCE}/{YAML_PATH}"
@@ -331,7 +340,7 @@ def test_content_api_summary_shape_anonymous(
resp = page.context.request.get(
f"{app_url}/api/documents/content?source={SOURCE}&path={YAML_URL_PATH}"
)
assert resp.status == 200, "anonymous viewer access must stay public"
assert resp.status == 200
body = resp.json()
assert body["source"] == SOURCE
assert body["path"] == YAML_PATH
@@ -343,7 +352,7 @@ def test_content_api_summary_shape_anonymous(
resp_md = page.context.request.get(
f"{app_url}/api/documents/content?source={SOURCE}&path={MD_URL_PATH}"
)
assert resp_md.status == 200, "anonymous viewer access must stay public"
assert resp_md.status == 200
md_body = resp_md.json()
assert md_body["format"] == "md"
assert md_body["summary"] is None, "markdown docs never carry a summary"
+10 -2
View File
@@ -44,13 +44,18 @@ from app.models import KbOverview
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]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCBTN", "8132"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The unique sentinel inside the fixture doc (task 03 step 1) — its
@@ -162,7 +167,10 @@ def _truncate_kb() -> None:
"""Fresh KB per test (the E2E isolation pattern): the sync's counts
and the ``kb_overview`` row must be the sync's own doing."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview"))
# git_sources too: the shared Postgres may carry leftover
# registry rows from other suites, and effective_sources prefers
# the DB list over this app's BOR_GIT_SOURCES env fallback.
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources"))
db.commit()
+12 -3
View File
@@ -65,13 +65,19 @@ from app.models import KbOverview
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]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module's healthy app binds its own port
# instead (a same-port second uvicorn dies on bind and would drive the
# wrong server); the dead-model app stays derived from it. Env-
# overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCDOWN", "8134"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The dead-model app's port — distinct so the isolated run never
#: clashes with the session app (``APP_PORT``) or the mock (8901).
@@ -241,9 +247,12 @@ def app_url(healthy_app_url: str) -> str:
def _truncate_kb() -> None:
"""Fresh KB per test (the E2E isolation pattern): any counts and the
``kb_overview`` row the healthy run produces are its own doing."""
``kb_overview`` row the healthy run produces are its own doing.
``git_sources`` too — the shared Postgres may carry leftover
registry rows that would take precedence over the apps' env
fallback repos."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview"))
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources"))
db.commit()
+6 -1
View File
@@ -102,7 +102,6 @@ from app.models import GitSource
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
MOCK_PORT,
SESSION_SECRET,
USE_REAL_LLM,
@@ -110,6 +109,12 @@ from e2e.conftest import (
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCUP", "8133"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
GIT_SOURCES_URL = "/git-sources.html"
+6 -5
View File
@@ -42,6 +42,7 @@ 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"
@@ -134,7 +135,7 @@ def test_thinking_block_streams_open_then_collapses(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", THINK_QUESTION)
page.evaluate("() => document.querySelector('#composer').requestSubmit()")
expect(page.locator(".msg.user .bubble").last).to_contain_text(THINK_QUESTION)
@@ -172,7 +173,7 @@ def test_thinking_block_streams_open_then_collapses(
def test_thinking_toggle_after_done(page: Page, app_url: str, seeded_kb: None) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
send_and_wait(page, THINK_QUESTION)
last = page.locator(".msg.brain").last
@@ -206,7 +207,7 @@ def test_thinking_toggle_after_done(page: Page, app_url: str, seeded_kb: None) -
def test_thinking_restored_after_reload(page: Page, app_url: str, seeded_kb: None) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
send_and_wait(page, THINK_QUESTION)
# The live block is collapsed; capture what it shows and what the
@@ -245,7 +246,7 @@ def test_thinking_restored_after_reload(page: Page, app_url: str, seeded_kb: Non
def test_no_thinking_block_without_trigger(page: Page, app_url: str, seeded_kb: None) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
send_and_wait(page, PLAIN_QUESTION)
# No trigger → no thinking events → no block anywhere on the page.
@@ -265,7 +266,7 @@ def test_no_thinking_block_without_trigger(page: Page, app_url: str, seeded_kb:
def test_thinking_with_deflection(page: Page, app_url: str, seeded_kb: None) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
send_and_wait(page, THINK_DEFLECT_QUESTION)
last = page.locator(".msg.brain").last
+9 -8
View File
@@ -94,6 +94,7 @@ 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.mock_llm import compose_thinking
REPO = Path(__file__).resolve().parents[2]
@@ -288,7 +289,7 @@ def test_thinking_window_user_scrollable(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
submit(page, HESITATE_QUESTION)
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
@@ -391,7 +392,7 @@ def test_thinking_window_follows_while_pinned(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
submit(page, HESITATE_QUESTION)
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
@@ -462,7 +463,7 @@ def test_thinking_window_stops_on_scroll_up(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
submit(page, HESITATE_QUESTION)
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
@@ -523,7 +524,7 @@ def test_thinking_window_resumes_on_return(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
submit(page, HESITATE_QUESTION)
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
@@ -595,7 +596,7 @@ def test_thinking_window_css_contract(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
send_and_wait(page, THINK_QUESTION)
details = page.locator(".msg.brain").last.locator("details.thinking")
@@ -625,7 +626,7 @@ def test_answer_bubble_still_scrollable(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(45_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
@@ -690,7 +691,7 @@ def test_restored_collapsed_thinking_unaffected(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
send_and_wait(page, THINK_QUESTION)
details = page.locator(".msg.brain").last.locator("details.thinking")
@@ -734,7 +735,7 @@ def test_thinking_window_follows_across_paragraph_breaks(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
submit(page, PARAS_QUESTION)
expect(page.locator(".msg.user .bubble").last).to_contain_text(PARAS_QUESTION)
+39
View File
@@ -0,0 +1,39 @@
"""Quick diagnostic: does the toggle still work after SPA navigation?"""
from playwright.sync_api import expect
from e2e.auth_helpers import login
def test_toggle_after_spa_nav(browser, app_url, db_ready):
"""After SPA navigation, the hamburger toggle should still open the menu."""
page = browser.new_page(viewport={"width": 375, "height": 812})
try:
login(page, app_url, next="/")
# Wait for whoami to settle (admin)
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
timeout=10_000,
)
# Open the menu
page.click("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
# Navigate to a different view via SPA
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html", timeout=15_000)
# Menu should be closed
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
# NOW try to open the menu again - THIS IS THE BUG CHECK
page.click("#nav-toggle")
try:
expect(page.locator("#nav-toggle")).to_have_attribute(
"aria-expanded", "true", timeout=3000
)
print("PASS: Toggle still works after SPA navigation")
except Exception as e:
print(f"FAIL: Toggle does NOT work after SPA navigation: {e}")
finally:
page.close()
+3 -2
View File
@@ -70,6 +70,7 @@ from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document
from e2e.auth_helpers import login
from tests.e2e.mock_llm import (
LS_TEACH_TRIGGER,
TOOLS_TRIGGER,
@@ -367,7 +368,7 @@ def test_ls_misuse_self_corrects_to_noarg_listing(
) -> None:
page.set_default_timeout(30_000)
_reset_db_fixture()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, LS_TEACH_QUESTION)
@@ -421,7 +422,7 @@ def test_plain_tool_flow_not_swallowed_by_new_trigger(
) -> None:
page.set_default_timeout(30_000)
_reset_db_fixture()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
# Turn 1 — the LS-TEACH flow (the incident's misuse → the
@@ -70,6 +70,7 @@ from sqlalchemy import select, text
from app.db import SessionLocal
from app.models import QueryLog
from e2e.auth_helpers import login
from tests.e2e.mock_llm import SCAFFOLD_RECOVERY_ANSWER, compose_answer
# --------------------------------------------------------------------------
@@ -291,7 +292,7 @@ def test_recovery_strips_scaffolding_and_streams_clean_answer(
) -> None:
page.set_default_timeout(30_000)
_reset_db_empty()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, RECOVERY_Q)
@@ -345,7 +346,7 @@ def test_terminal_scaffolding_lands_on_dedicated_error_app_stays_usable(
) -> None:
page.set_default_timeout(30_000)
_reset_db_empty()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, TERMINAL_Q)
@@ -406,7 +407,7 @@ def test_plain_turn_never_recovers_and_streams_byte_clean(
) -> None:
page.set_default_timeout(30_000)
_reset_db_empty()
page.goto(app_url)
login(page, app_url, next="/")
_install_sse_hook(page)
_submit(page, PLAIN_Q)
+2 -1
View File
@@ -40,6 +40,7 @@ from app.models import Chunk, Document, QueryLog
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from app.rag.retriever import TRUNCATION_MARKER
from e2e.auth_helpers import login
from tests.e2e.mock_llm import embed_text
REPO = Path(__file__).resolve().parents[2]
@@ -172,7 +173,7 @@ def _run_in_thread(coro: Any) -> Any:
def _ask(page: Page, app_url: str, question: str) -> Any:
"""Submit *question* and wait for the streamed brain bubble."""
page.set_default_timeout(30_000)
page.goto(app_url)
login(page, app_url, next="/")
page.fill("#message-input", question)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble")
+6 -4
View File
@@ -278,7 +278,7 @@ def test_chat_column_wide_vs_base(
at 1280×800 — the base below the breakpoint."""
wide = browser.new_page(viewport=WIDE_VIEWPORT)
try:
wide.goto(f"{app_url}/")
login(wide, app_url, next="/") # phase 79: the chips need a session
wide.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
@@ -289,7 +289,7 @@ def test_chat_column_wide_vs_base(
base = browser.new_page(viewport=BASE_VIEWPORT)
try:
base.goto(f"{app_url}/")
login(base, app_url, next="/") # phase 79: the chips need a session
base.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
@@ -316,6 +316,7 @@ def test_document_column_wide_vs_base(
wide = browser.new_page(viewport=WIDE_VIEWPORT)
try:
login(wide, app_url, next="/") # phase 79: the viewer content is gated
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)
@@ -331,6 +332,7 @@ def test_document_column_wide_vs_base(
base = browser.new_page(viewport=BASE_VIEWPORT)
try:
login(base, app_url, next="/") # phase 79: the viewer content is gated
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)
@@ -410,7 +412,7 @@ def test_narrow_unchanged(
content box instead, so 736px is the discriminator)."""
phone = browser.new_page(viewport={"width": 360, "height": 740})
try:
phone.goto(f"{app_url}/")
login(phone, app_url, next="/") # phase 79: the chips need a session
phone.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
@@ -421,7 +423,7 @@ def test_narrow_unchanged(
tablet = browser.new_page(viewport={"width": 900, "height": 800})
try:
tablet.goto(f"{app_url}/")
login(tablet, app_url, next="/") # phase 79: the chips need a session
tablet.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)