phase: 130_chat_answer_clusters
Build and Push Containers / build-and-push-app (push) Successful in 2m27s
Build and Push Containers / build-and-push-db (push) Successful in 12s

**Phase 130 — final verification pass: 100% green, complete.**

- **Verified:** chips-only row (no chip for image docs) → images-only row (`.msg-images`) → related row → `.msg-actions` last; `getActionsRow` used by all 3 buttons + stopped/failed notes; `markLastRetryable` save-re-append hack gone; in-place chip fallback on image-load failure; CSS right-align with per-button auto margins removed — implementation matches the design in every detail.
- **Completion criteria:** (1) mixed citation renders chips→images→actions-last, DOM-asserted ✅ (2) all 3 buttons in one right-aligned `.msg-actions`; non-admin sees Retry-only; restore path identical ✅ (3) 404-bytes figure → plain chip, unit-pinned ✅ (4) E2E isolated ✅ (5) no behavior change (modal affordances, related tier, ARIA names) ✅
- **Tests/lint/coverage:** `uv run pytest tests/e2e/test_chat_answer_clusters.py -v --no-cov` → 4 passed; `uv run pytest --cov=app --cov-report=term-missing` → 3061 passed, **99%** app coverage; the 4 E2E files this phase modified (test_answer_images, test_image_documents, test_pdf_support, test_responsive_polish) → 22 passed in isolation; `uv run ruff check .` clean, `uv run pyright` → 0 errors.
- **Docs:** README.md — verified consistent in full (page table vs routes, env-var coverage vs `.env.example`, phase-130 cluster wording already in tree); no changes needed. AGENTS.md — fixed one pre-existing inconsistency: rule 5 still cited the retired "centered 46rem chat column"; now states the 72rem container contract (phase 100).
- **Decisions:** PLAN.md §7.1 still says 46rem, but it's owner-managed (phase 100 records the supersession pending owner sign-off) and out of my editable scope — AGENTS.md now cross-references the supersession.
- **Next pending phase:** `131_read_line_ranges` (todo/).
This commit is contained in:
2026-09-28 07:49:37 -04:00
parent 2d0f608246
commit c88c65641a
46 changed files with 3266 additions and 351 deletions
+75 -57
View File
@@ -19,16 +19,18 @@ mock LLM and the shared e2e Postgres (truncate/re-import per app):
turn, the shape that makes the chip assertion meaningful). The
brain bubble shows the ANSWER FIGURE (``.answer-image`` — ``<img>``
from the bytes route, ``loading="lazy"``, below the text and above
the chip row per A4, alt/caption settling to the summary), the
answer TEXT is still streamed in full (the tail echo of the
the source clusters per A4, alt/caption settling to the summary),
the answer TEXT is still streamed in full (the tail echo of the
``<documents>`` seed proves the description was in the prompt), the
source-chip row carries the image doc's chip + its phase-122
figure (the AUTO-CITE proof — the mock made zero ``read`` calls,
asserted on the mock's request capture), and the "Nearby docs" row
does not repeat the doc. The wire pin: the first model request
offered FOUR tools (the three base + ``show_image``) and the turn
executed exactly one ``show_image`` — the saved brain record
persists the ``answer_image`` ref.
sources block carries the image doc's citation as the compact
figure in the images row (``.msg-meta.msg-images`` — phase 130
task 01: NO chip for an image doc, the figure IS the citation;
the AUTO-CITE proof — the mock made zero ``read`` calls, asserted
on the mock's request capture), and the "Nearby docs" row does not
repeat the doc. The wire pin: the first model request offered
FOUR tools (the three base + ``show_image``) and the turn executed
exactly one ``show_image`` — the saved brain record persists the
``answer_image`` ref.
- **Restore + share** — a second feature turn, then a page RELOAD:
the restored brain bubble shows the figure again (the
``answer_image`` persistence through the ``bor.chat.v1`` restore
@@ -36,11 +38,12 @@ mock LLM and the shared e2e Postgres (truncate/re-import per app):
fresh anonymous context) renders the figure on the shared page.
- **Bug-fix-only flow** — the ``which diagram is in your notes``
trigger (the mock answers straight from the seed with ZERO tool
calls — no ``show_image``, no ``read``): the source-chip row STILL
carries the image doc's chip + figure (the bug fix on the pure
zero-read path — the assertion the owner's report describes), the
answer text quotes the seeded description's tail, and NO answer
figure renders (the model never called the tool).
calls — no ``show_image``, no ``read``): the sources block STILL
carries the image doc's citation figure (no chip — phase 130;
the bug fix on the pure zero-read path — the assertion the owner's
report describes), the answer text quotes the seeded
description's tail, and NO answer figure renders (the model never
called the tool).
* **App B (default off)** — ``BOR_IMAGES=true``, ``BOR_ANSWER_IMAGES``
pinned to the code default ``false`` (EXPLICITLY — process env ranks
above an operator's local gitignored ``.env``, so the default-off
@@ -51,9 +54,10 @@ mock LLM and the shared e2e Postgres (truncate/re-import per app):
``show_image`` among the offered tools (the request capture carries
exactly the THREE base tools), the brain bubble has NO answer
figure, and the saved brain record carries NO ``answer_image`` key
(the byte-identical-off contract). The bug-fix trigger STILL chips
the image doc (the chips fix is NOT gated by ``BOR_ANSWER_IMAGES`` —
it belongs to the ``BOR_IMAGES``-indexed docs, per the phase design).
(the byte-identical-off contract). The bug-fix trigger STILL cites
the image doc (the figure, no chip — phase 130; the auto-cite fix is
NOT gated by ``BOR_ANSWER_IMAGES`` — it belongs to the
``BOR_IMAGES``-indexed docs, per the phase design).
DB isolation: the suites share one Postgres; ``saved_chats`` survives
the truncation (the house pattern), so every question text here is
@@ -593,15 +597,18 @@ def _assert_answer_figure(
def _assert_image_chip(
page: Page, seeded: dict[str, str], *, last_brain: str = ".msg.brain"
) -> None:
"""The citation chip the AUTO-CITE earns (phase 124 task 05): the
image doc's text chip + its phase-122 compact figure (``<img>``
from the bytes route, alt settling to the summary — the admin
surface; the shared page's chip is plain text and asserted
separately)."""
"""The AUTO-CITE (phase 124 task 05) in the phase-130 cluster
shape (task 01, TODO L6): an image doc gets NO chip — its figure
IS the citation — the compact figure (``<img>`` from the bytes
route, alt settling to the summary — the admin surface; the
shared page's plain-text chip is asserted separately) sits in the
images row (``.msg-meta.msg-images``)."""
chip = page.locator(f"{last_brain} .source-chip")
expect(chip).to_have_count(1)
expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
fig = page.locator(f"{last_brain} .source-image")
expect(chip).to_have_count(0)
row = page.locator(f"{last_brain} .msg-meta.msg-images")
expect(row).to_have_count(1)
expect(row).to_have_attribute("aria-label", "Source images")
fig = row.locator(".source-image")
expect(fig).to_have_count(1)
img = fig.locator(".source-image-img")
expect(img).to_be_visible(timeout=15_000)
@@ -612,7 +619,7 @@ def _assert_image_chip(
def _body_child_classes(page: Page) -> list[str]:
"""The LAST brain bubble's ``.msg-body`` direct children's class
names, in DOM order — the A4 position pin (bubble < answer-image
< msg-meta)."""
< the first .msg-meta-family source row)."""
return page.evaluate(
"""() => {
const body = [...document.querySelectorAll(".msg.brain")].pop()
@@ -635,15 +642,18 @@ def test_feature_flow_answer_figure_chips_and_zero_reads(
a ``show_image`` call (NO ``read`` — the zero-read turn). Asserts:
(1) the brain bubble's ``.msg-body`` carries ``.answer-image`` with
an ``<img>`` from the bytes route (200, image Content-Type) and
``loading="lazy"``; (2) the figure sits BEFORE the ``.msg-meta``
source-chip row (A4); (3) the answer TEXT is non-empty and quotes
the seed's tail (the "summaries still provided" contract + the
echo proving the seed was in the prompt); (4) the source-chip row
carries the image doc's chip + its figure (the AUTO-CITE proof —
the mock made ZERO ``read`` calls: only ``show_image`` — asserted
on the wire); (5) the "Nearby docs" row does NOT repeat the doc.
Plus the persistence pin: the saved brain record carries the
``answer_image`` ref (the task-04 frame field, round-tripped)."""
``loading="lazy"``; (2) the figure sits BELOW the answer text and
ABOVE the source clusters (A4 — the image-only KB renders the
images row, ``.msg-meta.msg-images`` — phase 130 task 01: no chip
row); (3) the answer TEXT is non-empty and quotes the seed's tail
(the "summaries still provided" contract + the echo proving the
seed was in the prompt); (4) the images row carries the image
doc's figure — no chip (phase 130: the figure IS the citation) —
the AUTO-CITE proof (the mock made ZERO ``read`` calls: only
``show_image`` — asserted on the wire); (5) the "Nearby docs" row
does NOT repeat the doc. Plus the persistence pin: the saved
brain record carries the ``answer_image`` ref (the task-04 frame
field, round-tripped)."""
login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
@@ -664,20 +674,25 @@ def test_feature_flow_answer_figure_chips_and_zero_reads(
# summary (the fetchContentSummary pattern settles both).
_assert_answer_figure(page, app_url, seeded, caption=seeded["description"])
# (2) A4 position: below the answer text, above the chip row.
# (2) A4 position: below the answer text, above the source
# clusters — the image-only KB renders the images row
# (.msg-meta.msg-images, phase 130 task 01: no chip row).
classes = _body_child_classes(page)
assert "bubble" in classes, f"no answer bubble in .msg-body: {classes}"
assert "answer-image" in classes, f"no .answer-image in .msg-body: {classes}"
assert "msg-meta" in classes, f"no chip row in .msg-body: {classes}"
meta_idx = next(
(i for i, c in enumerate(classes) if c.startswith("msg-meta")), None
)
assert meta_idx is not None, f"no source row in .msg-body: {classes}"
assert (
classes.index("bubble")
< classes.index("answer-image")
< classes.index("msg-meta")
), f"A4 order violated (bubble < answer-image < msg-meta): {classes}"
< meta_idx
), f"A4 order violated (bubble < answer-image < source row): {classes}"
# (4) The chip row carries the image doc's chip + its figure —
# the AUTO-CITE (the model never read it; the seed IS its
# entire content, so suggested == read for it).
# (4) The images row carries the image doc's figure (no chip —
# phase 130) — the AUTO-CITE (the model never read it; the
# seed IS its entire content, so suggested == read for it).
_assert_image_chip(page, seeded)
# (5) The "Nearby docs" row does NOT repeat the image doc — the
@@ -817,8 +832,8 @@ def test_reload_restores_figure_and_shared_page_shows_it(
# ---------------------------------------------------------------------------
# 3. App A — the bug-fix-only flow: ZERO tool calls, the chip STILL
# appears (the pure zero-read path — the owner's report)
# 3. App A — the bug-fix-only flow: ZERO tool calls, the citation
# STILL renders (the pure zero-read path — the owner's report)
# ---------------------------------------------------------------------------
@@ -828,11 +843,12 @@ def test_bugfix_only_flow_zero_tool_calls_still_chips(
"""The ``which diagram is in your notes`` trigger: the mock
answers straight from the seed with ZERO tool calls (no
``show_image`` — the clause's flow is not triggered — and no
``read``): the source-chip row STILL carries the image doc's chip
+ figure (the bug fix on the pure zero-read path — the assertion
the owner's report describes), the answer text quotes the seeded
description's tail (the description WAS in the prompt), and NO
answer figure renders (the model never called the tool)."""
``read``): the sources block STILL carries the image doc's
citation figure (no chip — phase 130; the bug fix on the pure
zero-read path — the assertion the owner's report describes), the
answer text quotes the seeded description's tail (the description
WAS in the prompt), and NO answer figure renders (the model never
called the tool)."""
login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
@@ -860,8 +876,9 @@ def test_bugfix_only_flow_zero_tool_calls_still_chips(
expect(page.locator(".msg.brain .tool-calls-disclosure")).to_have_count(0)
# NO answer figure (the model never called show_image) — but the
# chip row STILL carries the image doc's chip + figure (the auto-
# cite: suggested == read for an image doc, no read performed).
# sources block STILL carries the image doc's citation figure
# (no chip — phase 130; the auto-cite: suggested == read for an
# image doc, no read performed).
expect(page.locator(".msg.brain .answer-image")).to_have_count(0)
_assert_image_chip(page, seeded)
@@ -936,8 +953,8 @@ def test_toggle_off_flow_no_tool_no_answer_image_key(
# ---------------------------------------------------------------------------
# 5. App B (default off) — the bug-fix trigger STILL chips the image
# doc (the chips fix belongs to BOR_IMAGES, not BOR_ANSWER_IMAGES)
# 5. App B (default off) — the bug-fix trigger STILL cites the image
# doc (the auto-cite fix belongs to BOR_IMAGES, not BOR_ANSWER_IMAGES)
# ---------------------------------------------------------------------------
@@ -946,10 +963,11 @@ def test_toggle_off_bugfix_trigger_still_chips(
) -> None:
"""The bug-fix trigger on the default-OFF app (the clause is
absent — the trigger does NOT require it): ZERO tool calls, the
answer quotes the seeded description's tail, and the source-chip
row STILL carries the image doc's chip + figure — the chips fix
is NOT gated by ``BOR_ANSWER_IMAGES`` (it belongs to the
``BOR_IMAGES``-indexed docs, per the phase design)."""
answer quotes the seeded description's tail, and the sources
block STILL carries the image doc's citation figure (no chip —
phase 130) — the auto-cite fix is NOT gated by
``BOR_ANSWER_IMAGES`` (it belongs to the ``BOR_IMAGES``-indexed
docs, per the phase design)."""
login(page, default_app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
+939
View File
@@ -0,0 +1,939 @@
"""Phase 130 task 04 E2E (Playwright, mock-only): the chat answer renders
as organized clusters — chips row, images row, bottom-right actions row.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_chat_answer_clusters.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the story's gate
is the deterministic cluster DOM of the rendered answer, which needs the
``done`` frame to cite ONE text doc AND ONE image doc in the same
answer. That mixed citation is driven by the house scripted turn in
``tests/e2e/mock_llm.py`` (``DRILL_TRIGGER``: the user message contains
``drill down the tree`` — the question carries its own tool call after
the colon, ``drill down the tree: read e2e-clusters/note.md`` — **and**
the system prompt carries the ``<tools>`` section of the HIGH prompt).
The scripted ``read`` cites the text doc (the phase-119 read-docs-only
rule) while the image doc joins the citation tier auto-cited (its
suggested-tier seed block IS its entire content — phase 124): the
``done`` frame's ``sources`` carries exactly the two refs — the text
ref WITHOUT ``image_url`` (the phase-122 omission rule) and the image
ref WITH it — the shape phase 130 renders as two clusters (chips row
text-only, images row figures-only, NO chip for the image doc) + the
actions row last.
KB fixture — ONE local source (a ``tmp_path_factory`` dir, registered
through the authenticated API + the real in-process ``POST /api/sync``
— the ``test_local_directory_sources.py`` / ``test_ls_tree_drilldown.
py`` pattern):
* ``e2e-clusters/note.md`` — a markdown doc whose body carries the
trigger phrase's words + the diagram vocabulary: the mock's
token-overlap cosine against the scripted question clears the
mock-calibrated 0.30 gate (the turn grounds, the ``<tools>`` section
is present — the flow's precondition);
* ``e2e-clusters/pic.png`` — a standalone 1×1 PNG: the module app runs
``BOR_IMAGES=true`` (the phase-122 pattern — the mock's
``IMAGE_DESCRIPTION_MODE`` writes the byte-stable
``IMAGE_DESCRIPTION_ANSWER`` as the doc's content + summary).
The module app ALSO pins ``BOR_DOCS_REPO`` to a non-empty scratch path
(the ``docs_repo_configured`` gate is ``bool(docs_repo.strip())`` — a
configured repo reveals the Save-as-doc button for the admin; the push
endpoint is never exercised — the button is never clicked) and
``BOR_ANSWER_IMAGES=false`` explicitly (the leak-guard convention: the
owner's local gitignored ``.env`` cannot leak the answer-image toggle
into the premise — the scripted turn makes NO ``show_image`` call, so
the bubble carries no ``.answer-image``).
Test → observable mapping (Playwright Mapping Rule):
1. ``test_answer_clusters_render_in_order`` — the LIVE turn: the wire
carries the scripted ``read`` once, and the ``done`` frame cites
BOTH docs — the read text doc first (read order), then the
auto-cited image ref (``image_url`` present; the text ref's key is
ABSENT); the last brain bubble's ``.msg-body`` direct children are
exactly ``[tool-calls-disclosure, bubble, msg-meta, msg-meta
msg-images, msg-meta msg-actions]`` — the chips row (EXACTLY the
text chip — NO chip for the image doc, phase 130 task 01) before
the images row (the figure, ``<img>`` loaded from the bytes route,
alt + caption settling to the canned description), no related row
(the 2-doc KB has no related tier), and the actions row LAST
(the "bottom" of the TODO's "bottom right") holding ALL THREE gated
buttons (admin + configured docs repo — the phase-16/59 gates).
Geometry (bounding boxes, not pixels): the actions row's box is
BELOW the images row's, and the rightmost button's right edge sits
at the row's right edge minus the row's 0.25rem padding (the
per-button auto margins are gone — the row's
``justify-content: flex-end`` does the right-align, phase 130
task 03).
2. ``test_image_without_chip_keeps_its_citation_via_figure`` — the
same bubble: the ONLY ``.source-chip`` is the text doc's (the
owner's exact ask — no chip whose label is the image's
``source/path``), while the image doc is fully cited: the figure's
``href`` is its ``documentUrl`` and a left-click opens the same-
page modal (the phase-26 affordance — the modal shows the image +
the description). The text chip keeps its own phase-26 affordance
(left-click → the same modal) — the no-behavior-change half.
3. ``test_restore_renders_the_same_clusters`` — the turn auto-saves
(phase 66); a page reload restores it through the phase-14 restore
path (the same appenders): the restored last brain bubble carries
the IDENTICAL ``.msg-body`` child sequence (chips row text-only,
images row, actions row LAST with the three gated buttons) and the
same geometry — the DOM order assertion pinned for BOTH the live
and the restored bubble.
4. ``test_non_admin_sees_retry_only_actions`` — a FRESH non-admin
browser context (no admin cookie — the real in-app token gate,
``login_with_token``, the phase-79 pattern) on a FRESH chat: the
same scripted turn, and the ``.msg-actions`` row holds ONLY the
``.retry-btn`` (Tune admin-gated phase 16, Save-as-doc admin-
gated phase 59 — the gates unchanged) while the cluster rows
render identically (the render is not identity-gated).
DB isolation: the suites share one Postgres. ``saved_chats`` survives
the truncation (the house pattern) — every question text here is
UNIQUE (its auto-title therefore is) and the module boundary cleans
this suite's rows by title. The non-admin turn's auto-save 403s
(``/api/chats`` is admin-only — the quiet A2 status note, no row) and
its token is revoked at the end of the test.
"""
from __future__ import annotations
import base64
import json
import os
import subprocess
import sys
import time
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
import httpx
import pytest
from playwright.sync_api import Browser, Locator, Page, expect
from sqlalchemy import select, text
from app.config import Settings
from app.db import SessionLocal
from app.models import Document
from e2e.auth_helpers import login, login_with_token
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER
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_CLUSTERS", "8170"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
SETTLE_TIMEOUT_MS = 30_000
FIGURE_TIMEOUT_MS = 15_000
# --------------------------------------------------------------------------
# The fixture (one local source: ONE text doc + ONE image doc)
# --------------------------------------------------------------------------
SOURCE_NAME = "e2e-clusters" # the directory basename = the source name
TEXT_PATH = "note.md"
IMG_PATH = "pic.png"
TEXT_SP = f"{SOURCE_NAME}/{TEXT_PATH}" # the chip's label (source/path)
IMG_SP = f"{SOURCE_NAME}/{IMG_PATH}"
TEXT_TITLE = "Homelab Network Note" # the first H1 (extract_title)
IMG_TITLE = "pic" # the image doc's title = the file stem (phase 122)
#: The chip/figure ``href`` (the phase-26 same-page-modal affordance —
#: ``documentUrl(source, path, "/")`` — the /document.html escape hatch).
TEXT_DOC_URL = f"/document.html?source={SOURCE_NAME}&path={TEXT_PATH}&back=%2F"
IMG_DOC_URL = f"/document.html?source={SOURCE_NAME}&path={IMG_PATH}&back=%2F"
#: The text doc's body carries the trigger phrase's words + the diagram
#: vocabulary, so the scripted questions (which repeat them) clear the
#: mock's token-overlap cosine gate deterministically (~0.5 vs the
#: mock-calibrated 0.30).
TEXT_CONTENT = (
"# Homelab Network Note\n"
"\n"
"The drill down the tree fixture note for the e2e clusters suite: this\n"
"homelab note describes the network diagram — the core router, the core\n"
"switch, and the three VLAN subnets the homelab network diagram shows.\n"
)
# The scripted turn's answer (the phase-37 single-read shape): the mock
# quotes the read document's FIRST 80 chars — the H1 line (renders as a
# heading) + 56 body chars (the assertable prefix below, one text node).
READ_ANSWER_PREFIX = f"Read {TEXT_SP}."
READ_BODY_QUOTE = "The drill down the tree fixture note for the e2e cluster"
# A real 1×1 transparent PNG (the unit/integration suites' fixture —
# the pipeline is content-agnostic, the well-formed bytes keep the
# upload + serve + render path honest).
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
)
# --------------------------------------------------------------------------
# The scripted questions (the mock's ``DRILL_TRIGGER`` — the regex
# ``drill down the tree:\s*read\s+[a-z0-9_./-]+`` parses the read
# target from the RAW message, so the trigger line stays lowercase and
# the path stays in its character set). Each tail is unique — the
# auto-titles are unique on the shared e2e Postgres (the row-lookup
# convention of test_answer_images).
# --------------------------------------------------------------------------
Q_ORDER = (
"drill down the tree: read e2e-clusters/note.md — "
"what does the homelab note say about the diagram?"
)
Q_FIGURE = (
"drill down the tree: read e2e-clusters/note.md — "
"which diagram does the note describe?"
)
Q_RESTORE = (
"drill down the tree: read e2e-clusters/note.md — "
"summarize the homelab network note (restore)"
)
Q_NONADMIN = (
"drill down the tree: read e2e-clusters/note.md — "
"what is the note's diagram? (non-admin)"
)
assert all(len(q) <= 120 for q in (Q_ORDER, Q_FIGURE, Q_RESTORE, Q_NONADMIN)), (
"the auto-title cap is 120 chars — the questions must stay whole"
)
#: The .msg-body child sequence of a completed scripted-read bubble
#: (phase 130's fix, DOM-asserted): the tool record (the scripted read),
#: the answer bubble, the chips row (the bare .msg-meta), the images
#: row, the actions row LAST. The live and restore paths share it.
EXPECTED_CHILD_CLASSES = [
"tool-calls-disclosure",
"bubble",
"msg-meta",
"msg-meta msg-images",
"msg-meta msg-actions",
]
# ---------------------------------------------------------------------------
# Module app: images ON + a configured docs repo (the Save-as-doc gate)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def cluster_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The one-source fixture tree (see the module docstring): the app
server runs on the same host, so the paths are visible to it. The
directory NAME is the source name (``kind=local`` → the directory's
basename, phase 38)."""
scratch = tmp_path_factory.mktemp("bor_clusters")
src = scratch / SOURCE_NAME
src.mkdir()
(src / TEXT_PATH).write_text(TEXT_CONTENT, encoding="utf-8")
(src / IMG_PATH).write_bytes(PNG_1X1)
assert (src / TEXT_PATH).is_file() and (src / IMG_PATH).is_file()
return src
@pytest.fixture(scope="module")
def app_server(mock_llm: int, cluster_dir: Path) -> Iterator[str]:
"""The real app under test — per-module app (the conftest pattern):
NO ``BOR_GIT_SOURCES`` (the env fallback is git-only — the source
here is a DB-registered local directory), ``BOR_IMAGES=true`` (the
PNG must become an image document), a configured ``BOR_DOCS_REPO``
(the Save-as-doc reveal gate), and the leak-guarded code defaults.
The session app is never started in this isolated run, so no port
clash."""
scratch = cluster_dir.parent # the tmp dir holding the source tree
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated gate (conftest pattern): the scripted questions
# ground on these values (see TEXT_CONTENT).
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
# Phase 67: instant retry waits + the code-default budget.
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# The repo's .env file carries the owner's BOR_GIT_SOURCES (the app
# reads it from cwd) — override it with an EMPTY value: the registry
# must hold EXACTLY this suite's local directory.
env["BOR_GIT_SOURCES"] = ""
# Leak guards (conftest pattern): an operator's local (gitignored)
# .env cannot leak corpus-specific settings into the app under test.
env["BOR_DOCS_REPO"] = str(scratch / "docs") # non-empty → configured
env["BOR_DOCS_WORK_DIR"] = str(scratch / "docs-work")
env["BOR_SUGGESTIONS"] = json.dumps(
Settings.model_fields["suggestions"].default
)
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
# Phase 122: the PNG must become an image document; the image/upload
# homes are in the suite's scratch (never the owner's real
# ~/bor-sources). The answer-image toggle is pinned OFF explicitly —
# the scripted turn makes no show_image call, so no .answer-image.
env["BOR_IMAGES"] = "true"
env["BOR_ANSWER_IMAGES"] = "false"
env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
env["BOR_IMAGE_DIR"] = str(scratch / "images")
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
# ---------------------------------------------------------------------------
# DB + admin-HTTP helpers
# ---------------------------------------------------------------------------
def _truncate_all() -> None:
"""Fresh registry + KB (the E2E isolation pattern): the suites share
one Postgres, and a leftover row would corrupt the 2-doc premise.
``saved_chats`` is never touched — the unique question texts keep
the row lookups unambiguous, and :func:`_cleanup_saved_rows` owns
this suite's rows (re-runnable on the shared DB)."""
with SessionLocal() as db:
db.execute(text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, git_sources, sources_meta, folder_summaries"
))
db.commit()
def _health_db(url: str) -> bool:
try:
return httpx.get(f"{url}/api/health", timeout=5).json()["db"] == "up"
except Exception: # noqa: BLE001 — unreachable is the skip case
return False
@contextmanager
def _admin_client(app_url: str) -> Iterator[httpx.Client]:
"""An httpx client holding a fresh admin session (the form login's
API twin — the house httpx pattern of test_ls_tree_drilldown's
``synced_kb``), closed on exit."""
client = httpx.Client(base_url=app_url, timeout=30.0)
try:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
yield client
finally:
client.close()
def _wait_sync_done_http(client: httpx.Client, timeout_s: float = 180.0) -> dict[str, Any]:
"""Poll the (cookie-authenticated) status endpoint until the run
reaches a terminal state (the test_local_directory_sources pattern)."""
deadline = time.monotonic() + timeout_s
body: dict[str, Any] = {}
while time.monotonic() < deadline:
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
body = r.json()
if body["state"] in ("success", "failed"):
return body
time.sleep(0.5)
raise AssertionError(f"sync did not reach a terminal state: {body}")
def _auto_title(question: str) -> str:
"""The phase-50 auto-title convention: the first question,
whitespace-collapsed, capped at 120 chars."""
return " ".join(question.split())[:120]
#: This suite's auto-titles (the shared e2e Postgres keeps
#: ``saved_chats`` across runs — the test_answer_images cleanup
#: concern): a re-run must start clean.
_SUITE_TITLES = tuple(
_auto_title(q) for q in (Q_ORDER, Q_FIGURE, Q_RESTORE, Q_NONADMIN)
)
def _cleanup_saved_rows() -> None:
"""Delete THIS suite's saved rows by title (best-effort: a failed
delete (DB unreachable — the skip case) never masks the skip)."""
try:
with SessionLocal() as db:
db.execute(
text("DELETE FROM saved_chats WHERE title = ANY(:titles)"),
{"titles": list(_SUITE_TITLES)},
)
db.commit()
except Exception: # noqa: BLE001 — DB unreachable: the skip handles it
pass
def _wait_saved_row(
client: httpx.Client, title: str, messages: int = 2
) -> dict[str, Any]:
"""Wait for the auto-saved row (phase 55/66: auto-saves are SILENT
— so there is no status line to wait on)."""
deadline = time.monotonic() + 15
last: dict[str, Any] | None = None
while time.monotonic() < deadline:
r = client.get("/api/chats", timeout=10)
assert r.status_code == 200
rows = r.json()["chats"]
last = next((c for c in rows if c["title"] == title), None)
if last is not None and last["message_count"] >= messages:
return last
time.sleep(0.2)
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
def _create_token(client: httpx.Client, label: str) -> tuple[str, str]:
"""Admin-issued token (``POST /api/tokens`` — the house pattern of
test_api_tokens): returns (row id, plaintext)."""
r = client.post("/api/tokens", json={"label": label}, timeout=10)
assert r.status_code == 201, r.text
body = r.json()
assert body["label"] == label
return body["id"], body["token"]
def _revoke_token(client: httpx.Client, token_id: str) -> None:
"""Revoke the token (a revoked token is dead NOW — A10): the shared
DB keeps no live credential behind the suite."""
r = client.post(f"/api/tokens/{token_id}/revoke", timeout=10)
assert r.status_code == 204, r.text
@pytest.fixture(scope="module")
def seeded(app_url: str, cluster_dir: Path) -> Iterator[dict[str, str]]:
"""The story's precondition: the one-source KB (ONE text doc + ONE
image doc) synced under the deterministic mock — registered through
the authenticated API, scanned by the REAL in-process
``POST /api/sync`` pipeline (walk → chunk → embed → the mock's
vision description for the PNG → overview → version bump). Yields
the seeded image doc's identity (the bytes route + the
description) for the render assertions."""
if not _health_db(app_url):
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
_cleanup_saved_rows() # a previous run's rows would be stale (new doc id)
_truncate_all()
with _admin_client(app_url) as client:
r = client.post(
"/api/git-sources", json={"kind": "local", "path": str(cluster_dir)}
)
assert r.status_code == 201, r.text
r = client.post("/api/sync")
assert r.status_code == 202, r.text
body = _wait_sync_done_http(client)
assert body["state"] == "success", body
detail = body["detail"]
assert detail["added"] == 2, detail # the text doc + the image doc
with SessionLocal() as db:
text_doc = db.scalar(
select(Document).where(
Document.source == SOURCE_NAME, Document.path == TEXT_PATH
)
)
img_doc = db.scalar(
select(Document).where(
Document.source == SOURCE_NAME, Document.path == IMG_PATH
)
)
assert text_doc is not None, "the text doc must be indexed"
assert img_doc is not None, "the PNG must become a document"
assert img_doc.is_image is True and img_doc.image_path is not None
# content == summary == the mock's description (the ONLY embedded
# text of the image doc — the phase-122 pipeline contract).
assert img_doc.content == IMAGE_DESCRIPTION_ANSWER
assert img_doc.summary == IMAGE_DESCRIPTION_ANSWER
assert text_doc.title == TEXT_TITLE # the first H1 (extract_title)
assert img_doc.title == IMG_TITLE # the file stem (phase 122)
yield {
"image_url": f"/api/documents/{img_doc.id}/image",
"description": IMAGE_DESCRIPTION_ANSWER,
}
_cleanup_saved_rows()
_truncate_all()
@pytest.fixture(autouse=True)
def _clean(db_ready: None) -> Iterator[None]:
"""Per-test query_log isolation (the KB itself is module-scoped —
the scripted turns never change it, so the registry and the docs
persist across the tests of this module)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE query_log"))
db.commit()
yield
with SessionLocal() as db:
db.execute(text("TRUNCATE query_log"))
db.commit()
# ---------------------------------------------------------------------------
# Page helpers (the test_ls_tree_drilldown house pattern)
# ---------------------------------------------------------------------------
#: Captures the raw SSE ``data:`` payloads of the /api/chat stream
#: (a response clone read in the background) — wire-level assertions
#: for the ``done`` frame's mixed citation, independent of the UI.
SSE_HOOK = """
() => {
if (window.__sseInstalled) return;
window.__sseInstalled = true;
window.__sseFrames = [];
const origFetch = window.fetch;
window.fetch = async function (...args) {
const res = await origFetch.apply(this, args);
try {
const url = typeof args[0] === 'string' ? args[0] : args[0].url;
if (url.includes('/api/chat')) {
res.clone().text().then((bodyText) => {
for (const block of bodyText.split('\\n\\n')) {
const line = block.trim();
if (line.startsWith('data: ')) {
window.__sseFrames.push(line.slice(6));
}
}
});
}
} catch (e) { /* non-clonable responses: ignored */ }
return res;
};
}
"""
def _install_page_hooks(page: Page) -> None:
page.evaluate(SSE_HOOK)
def _frames(page: Page) -> list[dict[str, Any]]:
"""The SSE frames captured since the last submit (``_submit``
clears the buffer), once the hook's background read settles."""
deadline = time.monotonic() + 30.0
while True:
raw = page.evaluate("() => window.__sseFrames || []")
parsed = [json.loads(line) for line in raw if line]
if any(f.get("type") == "done" for f in parsed):
return parsed
if time.monotonic() > deadline:
raise AssertionError(
f"SSE hook captured no `done` frame (frames so far: "
f"{len(parsed)}) — hook install failed?"
)
time.sleep(0.05)
def _tool_frames(frames: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [f for f in frames if f.get("type") == "tool"]
def _submit(page: Page, question: str) -> None:
page.evaluate("window.__sseFrames = []")
page.fill("#message-input", question)
page.click("#send-btn")
# The user bubble lands synchronously with the submit handler.
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
def _wait_settled(page: Page) -> None:
"""The turn is complete: answer text in the bubble, button recovered
(the phase-48 settle wait, the test_agent_document_tools helper)."""
expect(
page.locator(".msg.brain .bubble").last
).not_to_have_text("", timeout=SETTLE_TIMEOUT_MS)
expect(page.locator("#send-btn")).to_be_enabled(timeout=SETTLE_TIMEOUT_MS)
expect(page.locator("#send-label")).to_have_text("Send", timeout=SETTLE_TIMEOUT_MS)
def _wait_answer(page: Page) -> None:
"""The scripted read's answer landed (the settle's answer-side half
— used when the turn's wire is not asserted separately)."""
expect(
page.locator(".msg.brain .bubble").last
).to_contain_text(READ_ANSWER_PREFIX, timeout=SETTLE_TIMEOUT_MS)
_wait_settled(page)
def _last_brain(page: Page) -> Locator:
return page.locator(".msg.brain").last
def _body_child_classes(page: Page) -> list[str]:
"""The LAST brain bubble's ``.msg-body`` direct children's class
names, in DOM order — the cluster-order pin (the phase-130 fix,
asserted exactly: pre-phase markup — buttons inside the chips row,
figures interleaved — fails this sequence)."""
return page.evaluate(
"""() => {
const body = [...document.querySelectorAll(".msg.brain")].pop()
.querySelector(".msg-body");
return [...body.children].map((el) => el.className);
}"""
)
def _assert_cluster_rows(
page: Page,
seeded: dict[str, str],
*,
action_buttons: list[str],
) -> None:
"""The phase-130 cluster contract on the LAST brain bubble (the
live and restore paths share it): the chips row holds EXACTLY the
text doc's chip (NO chip for the image doc — the figure IS the
citation, phase 130 task 01), the images row holds the one figure
(the ``<img>`` loaded from the bytes route, alt + the visible
caption settling to the document's summary), and the actions row
— the bubble's LAST row — holds exactly the gated buttons
(*action_buttons*; the row-level right-align is
``_assert_actions_geometry``'s job)."""
bubble = _last_brain(page)
# The three rows exist exactly once each (the bare .msg-meta is the
# chips row — the images/actions/related rows carry their own
# classes).
chips_row = bubble.locator(
".msg-meta:not(.msg-images):not(.msg-actions):not(.related-docs)"
)
images_row = bubble.locator(".msg-meta.msg-images")
actions_row = bubble.locator(".msg-meta.msg-actions")
expect(chips_row).to_have_count(1)
expect(images_row).to_have_count(1)
expect(actions_row).to_have_count(1)
# ARIA is kept (the house meta-row convention, unchanged).
expect(chips_row).to_have_attribute("role", "list")
expect(chips_row).to_have_attribute("aria-label", "Sources")
expect(images_row).to_have_attribute("role", "list")
expect(images_row).to_have_attribute("aria-label", "Source images")
expect(actions_row).to_have_attribute("role", "list")
expect(actions_row).to_have_attribute("aria-label", "Answer actions")
# The chips row: EXACTLY the text doc's chip — and the bubble
# carries NO other chip anywhere (the image row has none — the
# owner's exact ask, TODO L6).
chips = chips_row.locator(".source-chip")
expect(chips).to_have_count(1)
expect(chips.first).to_have_text(TEXT_SP)
expect(chips.first).to_have_attribute("href", TEXT_DOC_URL)
expect(bubble.locator(".source-chip")).to_have_count(1)
# The images row: the one figure — the citation of the image doc.
# Its href is the documentUrl (the phase-26 escape hatch), the
# <img> loaded from the bytes route, alt + caption settling to the
# document's summary (the fetchContentSummary pattern).
fig = images_row.locator(".source-image")
expect(fig).to_have_count(1)
expect(fig).to_have_attribute("href", IMG_DOC_URL)
img = fig.locator(".source-image-img")
expect(img).to_be_visible(timeout=FIGURE_TIMEOUT_MS)
expect(img).to_have_attribute("src", seeded["image_url"])
expect(img).to_have_attribute("alt", seeded["description"], timeout=FIGURE_TIMEOUT_MS)
expect(fig.locator(".source-image-caption")).to_have_text(
seeded["description"], timeout=FIGURE_TIMEOUT_MS
)
# The actions row: exactly the gated buttons (the cluster — nothing
# else joins it: the notes are absent on a completed turn), and
# NOTHING after it — it is the bubble's LAST row (the "bottom" of
# "bottom right").
for cls in action_buttons:
expect(actions_row.locator(f".{cls}")).to_have_count(1)
expect(actions_row.locator("button")).to_have_count(len(action_buttons))
expect(actions_row.locator("~ *")).to_have_count(0)
def _assert_actions_geometry(page: Page) -> None:
"""The TODO's "bottom right" geometry (bounding boxes, not pixels):
the actions row is BELOW the images row, and the rightmost
button's right edge sits at the row's right edge minus the row's
0.25rem inline padding (≤ 5px — the per-button auto margins are
gone: the row's ``justify-content: flex-end`` does the
right-align, phase 130 task 03)."""
bubble = _last_brain(page)
a_box = bubble.locator(".msg-meta.msg-actions").bounding_box()
i_box = bubble.locator(".msg-meta.msg-images").bounding_box()
assert a_box and i_box, "the cluster rows must be laid out (visible)"
assert a_box["y"] >= i_box["y"] + i_box["height"] - 1, (
f"the actions row must be BELOW the images row: actions.y="
f"{a_box['y']} vs images bottom {i_box['y'] + i_box['height']}"
)
btn_box = (
bubble.locator(".msg-meta.msg-actions button").last.bounding_box()
)
assert btn_box, "the rightmost button must be laid out (visible)"
gap = (a_box["x"] + a_box["width"]) - (btn_box["x"] + btn_box["width"])
assert 0 <= gap <= 5, (
f"the rightmost button must sit at the row's right edge (the "
f"only slack is the row's 0.25rem padding): gap={gap}px"
)
# ---------------------------------------------------------------------------
# 1. The live turn: chips row → images row → actions row LAST (the
# owner-visible fix, DOM-asserted), the wire cites both docs
# ---------------------------------------------------------------------------
def test_answer_clusters_render_in_order(
page: Page, app_url: str, seeded: dict[str, str], db_ready: None
) -> None:
"""The scripted mixed-citation turn (the mock's ``DRILL_TRIGGER``
scripted read of the text doc; the image doc auto-cited — see the
module docstring): the ``done`` frame cites BOTH docs, and the
last brain bubble's ``.msg-body`` reads as organized clusters top
to bottom — the answer bubble, the chips row (EXACTLY the text
chip — NO chip for the image doc), the images row (the figure,
loaded, caption settled to the canned description), and the
actions row LAST with ALL THREE gated buttons (admin + configured
docs repo) — right-aligned and below the images row (the "bottom
right" geometry)."""
page.set_default_timeout(SETTLE_TIMEOUT_MS)
login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
_install_page_hooks(page)
_submit(page, Q_ORDER)
_wait_settled(page)
# The WIRE: exactly the scripted read (one tool frame), and the
# done frame carries the mixed citation — the read text doc FIRST
# (the phase-119 read order), then the auto-cited image ref:
# the text ref's ``image_url`` key is ABSENT (the phase-122
# omission rule), the image ref carries it. No related tier (the
# 2-doc KB), no answer figure (no show_image call).
frames = _frames(page)
assert _tool_frames(frames) == [
{"type": "tool", "name": "read", "argument": TEXT_SP}
], _tool_frames(frames)
done = next(f for f in frames if f.get("type") == "done")
assert done["deflected"] is False, done
assert done["sources"] == [
{"source": SOURCE_NAME, "path": TEXT_PATH, "title": TEXT_TITLE},
{
"source": SOURCE_NAME,
"path": IMG_PATH,
"title": IMG_TITLE,
"image_url": seeded["image_url"],
},
], done["sources"]
assert done["related"] == []
assert done["suggestions"] == []
assert "answer_image" not in done
# The scripted read reached the model and landed in the answer
# (the phase-37 citation shape: the prefix + the document's first
# 80 chars — the H1 renders as a heading, the body quote stays in
# its paragraph).
bubble = _last_brain(page).locator(".bubble")
expect(bubble).to_contain_text(READ_ANSWER_PREFIX)
expect(bubble).to_contain_text(TEXT_TITLE)
expect(bubble).to_contain_text(READ_BODY_QUOTE)
# The DOM ORDER (the fix): tool record, answer, chips row, images
# row, actions row LAST — asserted exactly (this sequence fails
# against the pre-phase soup div, where the buttons shared the
# chips row and the figure interleaved with it).
assert _body_child_classes(page) == EXPECTED_CHILD_CLASSES, (
_body_child_classes(page)
)
# The clusters + the actions row's contents (all three buttons —
# admin, docs repo configured) + nothing after the actions row.
_assert_cluster_rows(
page, seeded,
action_buttons=["tune-btn", "retry-btn", "save-as-doc-btn"],
)
_assert_actions_geometry(page)
# ---------------------------------------------------------------------------
# 2. No chip for the image doc — the figure is its full citation
# (documentUrl href + the phase-26 same-page modal)
# ---------------------------------------------------------------------------
def test_image_without_chip_keeps_its_citation_via_figure(
page: Page, app_url: str, seeded: dict[str, str], db_ready: None
) -> None:
"""The owner's exact ask in one bubble: NO ``.source-chip`` whose
label is the image's ``source/path`` (the ONLY chip is the text
doc's), while the image doc is fully cited — the figure's ``href``
is its ``documentUrl`` and a LEFT-CLICK opens the same-page modal
(the phase-26 affordance, the modal shows the image + the
description). The text chip keeps its own phase-26 affordance
(left-click → the same modal) — the no-behavior-change half."""
page.set_default_timeout(SETTLE_TIMEOUT_MS)
login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
_submit(page, Q_FIGURE)
_wait_answer(page)
bubble = _last_brain(page)
# The ONLY chip is the text doc's — there is NO chip for the image
# doc (the figure is the citation — TODO L6).
chips = bubble.locator(".source-chip")
expect(chips).to_have_count(1)
expect(chips.first).to_have_text(TEXT_SP)
# The figure is the image doc's full citation: the documentUrl
# href, and a left-click opens the same-page modal (phase 26).
fig = bubble.locator(".msg-meta.msg-images .source-image")
expect(fig).to_have_count(1)
expect(fig).to_have_attribute("href", IMG_DOC_URL)
fig.click()
expect(page.locator("#doc-modal")).to_be_visible(timeout=SETTLE_TIMEOUT_MS)
expect(page.locator("#doc-modal-title")).to_have_text(IMG_TITLE)
img = page.locator("#doc-modal .doc-image-img")
expect(img).to_have_count(1)
expect(img).to_have_attribute("src", seeded["image_url"])
expect(page.locator("#doc-modal pre.doc-raw")).to_have_text(
seeded["description"]
)
page.keyboard.press("Escape") # the phase-26 close affordance
expect(page.locator("#doc-modal")).to_be_hidden()
# The text chip keeps its own phase-26 affordance (the no behavior
# change half of the phase): left-click → the same modal, the
# text doc's title + content.
chips.first.click()
expect(page.locator("#doc-modal")).to_be_visible(timeout=SETTLE_TIMEOUT_MS)
expect(page.locator("#doc-modal-title")).to_have_text(TEXT_TITLE)
page.keyboard.press("Escape")
expect(page.locator("#doc-modal")).to_be_hidden()
# ---------------------------------------------------------------------------
# 3. Reload: the phase-14 restore path renders the IDENTICAL clusters
# ---------------------------------------------------------------------------
def test_restore_renders_the_same_clusters(
page: Page, app_url: str, seeded: dict[str, str], db_ready: None
) -> None:
"""The turn auto-saves (phase 66); a page RELOAD restores the
conversation through the phase-14 restore path (the same
appenders — ``renderStoredMessage`` + the after-the-loop
``markLastRetryable``): the restored last brain bubble carries the
IDENTICAL ``.msg-body`` child sequence (chips row text-only,
images row, ``.msg-actions`` LAST with the three gated buttons)
and the same "bottom right" geometry — the DOM order assertion
pinned for the restored bubble too."""
page.set_default_timeout(SETTLE_TIMEOUT_MS)
login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
_submit(page, Q_RESTORE)
_wait_answer(page)
_assert_cluster_rows(
page, seeded,
action_buttons=["tune-btn", "retry-btn", "save-as-doc-btn"],
)
_assert_actions_geometry(page)
live_children = _body_child_classes(page)
# The auto-save settled (the row exists with both messages) before
# the reload (phase 55/66 — the silent upsert).
with _admin_client(app_url) as client:
_wait_saved_row(client, _auto_title(Q_RESTORE))
# RELOAD: the localStorage restore (the done handler's save point
# ran synchronously, so the record is durable before we reload).
page.reload()
expect(page.locator("#kb-banner")).to_be_hidden()
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(".msg.brain .bubble")).to_have_count(1)
restored = page.locator(".msg.brain .bubble").last
expect(restored).to_contain_text(READ_ANSWER_PREFIX, timeout=SETTLE_TIMEOUT_MS)
# The IDENTICAL structure (the restore renders through the same
# appenders — the child sequence is the live one, exactly).
assert _body_child_classes(page) == EXPECTED_CHILD_CLASSES == live_children, (
_body_child_classes(page)
)
_assert_cluster_rows(
page, seeded,
action_buttons=["tune-btn", "retry-btn", "save-as-doc-btn"],
)
_assert_actions_geometry(page)
# ---------------------------------------------------------------------------
# 4. A non-admin (token user) sees ONLY the Retry in the actions row
# ---------------------------------------------------------------------------
def test_non_admin_sees_retry_only_actions(
browser: Browser,
app_url: str,
seeded: dict[str, str],
db_ready: None,
) -> None:
"""A FRESH non-admin context (a new browser context — no admin
cookie, no cached token, no localStorage → a FRESH chat) signs in
through the REAL in-app token gate (``login_with_token``, phase
79) with an admin-issued token, and sends the same scripted turn:
the cluster rows render identically (the render is not
identity-gated — chips + images + the ARIA names), and the
``.msg-actions`` row holds ONLY the ``.retry-btn`` (Tune admin-
gated phase 16, Save-as-doc admin-gated phase 59 — the gates
unchanged). The token is revoked at the end."""
with _admin_client(app_url) as client:
token_id, token = _create_token(client, "e2e-clusters-guest")
try:
ctx = browser.new_context(viewport={"width": 1280, "height": 800})
try:
anon = ctx.new_page()
anon.set_default_timeout(SETTLE_TIMEOUT_MS)
login_with_token(anon, app_url, token, next="/")
expect(anon.locator("#kb-banner")).to_be_hidden()
_submit(anon, Q_NONADMIN)
_wait_answer(anon)
bubble = _last_brain(anon)
# The cluster rows are identical for the token user
# (the actions row carries the retry-only cluster).
_assert_cluster_rows(anon, seeded, action_buttons=["retry-btn"])
# The gates, unchanged: no Tune (phase 16), no
# Save-as-doc (phase 59) — the ONE button in the row
# is the Retry.
actions = bubble.locator(".msg-meta.msg-actions")
expect(actions.locator(".tune-btn")).to_have_count(0)
expect(actions.locator(".save-as-doc-btn")).to_have_count(0)
expect(actions.locator("button")).to_have_count(1)
expect(actions.locator(".retry-btn")).to_have_count(1)
finally:
ctx.close()
finally:
_revoke_token(client, token_id)
+15 -12
View File
@@ -413,12 +413,13 @@ def test_chat_sources_block_shows_the_inline_image(
turn chips nothing): the mock's echo carries the agent ``read``
result VERBATIM — including the task-05 ``IMAGE_DOC_MARKER`` line
(the model saw the description, not raw bytes) — and the answer's
sources block carries the citation chip (the READ doc clears the
usefulness floor) AND the compact inline figure — the ``<img>``
from the bytes route, the visible caption + ``alt`` settling to
the document's summary (the figure fetches it from the content
endpoint — the frame carries no summary), the "shown in the chat
nicely" contract."""
sources block carries the citation (the READ doc clears the
usefulness floor) as the compact figure in the images row
(phase 130 task 01: NO chip for an image doc — the figure IS the
citation) — the ``<img>`` from the bytes route, the visible
caption + ``alt`` settling to the document's summary (the figure
fetches it from the content endpoint — the frame carries no
summary), the "shown in the chat nicely" contract."""
login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
@@ -433,15 +434,17 @@ def test_chat_sources_block_shows_the_inline_image(
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
# The citation chip (the text affordance stays — the figure is
# additive, not a replacement).
chip = page.locator(".msg.brain .source-chip")
expect(chip).to_have_count(1)
expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
# Phase 130 (task 01, TODO L6): NO chip for the image doc — its
# figure is the citation — and the figure sits in its own images
# cluster (.msg-meta.msg-images), not interleaved with a chip.
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
row = page.locator(".msg.brain .msg-meta.msg-images")
expect(row).to_have_count(1)
expect(row).to_have_attribute("aria-label", "Source images")
# The inline figure: image + caption, both settling to the summary
# (the content fetch resolves the alt/caption after the title).
fig = page.locator(".msg.brain .source-image")
fig = row.locator(".source-image")
expect(fig).to_have_count(1)
img = fig.locator(".source-image-img")
expect(img).to_be_visible(timeout=15_000)
+17 -10
View File
@@ -541,10 +541,13 @@ def test_chat_grounds_on_the_pdf_and_chips_the_embedded_image(
PDF (phase 119 A1): the echo carries the read result VERBATIM —
the document's FULL extracted text (LOCKED A4 — the page markers
and both page texts reach the model and land in the answer), and
the sources block carries the PDF's citation chip (the READ doc)
PLUS the embedded image's AUTO-cited chip (phase 124 — the
suggested-tier image doc) with its compact inline figure (the
bytes route, caption/alt settling to the description)."""
the sources block renders the mixed citation as phase-130
clusters (task 01): the chips row carries the PDF's citation chip
(the READ doc) and NO chip for the image doc (its figure IS the
citation), while the embedded image's AUTO-cite (phase 124 — the
suggested-tier image doc) sits in the images row as its compact
figure (the bytes route, caption/alt settling to the
description)."""
login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden()
@@ -563,17 +566,21 @@ def test_chat_grounds_on_the_pdf_and_chips_the_embedded_image(
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
# The citation chips: the READ PDF first (read order), then the
# suggested image doc's auto-cite (phase 124).
# Phase 130 (task 01) — the mixed citation: the chips row carries
# the READ PDF ONLY (no chip for the image doc — the figure IS
# the citation), and the image doc's auto-cite (phase 124) is the
# figure in its own images row.
chip = page.locator(".msg.brain .source-chip")
expect(chip).to_have_count(2)
expect(chip).to_have_count(1)
expect(chip.nth(0)).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}")
expect(chip.nth(1)).to_have_text(f"{SOURCE_NAME}/{IMAGE_REL}")
row = page.locator(".msg.brain .msg-meta.msg-images")
expect(row).to_have_count(1)
expect(row).to_have_attribute("aria-label", "Source images")
# The inline figure on the image chip: image + caption, both
# The inline figure in the images row: image + caption, both
# settling to the description (the content fetch resolves the
# summary after the title).
fig = page.locator(".msg.brain .source-image")
fig = row.locator(".source-image")
expect(fig).to_have_count(1)
img = fig.locator(".source-image-img")
expect(img).to_be_visible(timeout=15_000)
+7 -3
View File
@@ -429,7 +429,10 @@ def test_a11y_landmarks_and_labels(
f"skip link must move focus to #main ({path})"
)
# Every button has an accessible name; every img has alt text.
# Every button has an accessible name; every img carries an alt
# ATTRIBUTE (the WCAG contract — an EMPTY alt is the legitimate
# decorative marker, e.g. the phase-123 attach-preview
# thumbnail, pinned in tests/unit/test_chat_image_questions.py).
unnamed = page.evaluate(
"""() => [...document.querySelectorAll("button")]
.filter((b) => !(b.getAttribute("aria-label") || b.textContent.trim()))
@@ -437,9 +440,10 @@ def test_a11y_landmarks_and_labels(
)
assert unnamed == 0, f"{unnamed} button(s) without an accessible name on {path}"
bad_imgs = page.evaluate(
"() => [...document.querySelectorAll('img')].filter((i) => !i.alt).length"
"""() => [...document.querySelectorAll('img')]
.filter((i) => !i.hasAttribute('alt')).length"""
)
assert bad_imgs == 0, f"{bad_imgs} <img> without alt on {path}"
assert bad_imgs == 0, f"{bad_imgs} <img> without an alt attribute on {path}"
# A real keyboard Tab walk: every focused element shows a visible
# :focus-visible outline (solid, >= 2px). Start from the top of the