feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
@@ -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}/")
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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", (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())"
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user