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
+421
View File
@@ -0,0 +1,421 @@
"""Unit: the phase-130 (task 02, TODO L6) chat actions cluster.
The three answer actions (Tune / Retry / Save-as-doc) and the
answer-status notes (Stopped / Failed) stop joining whatever the first
``.msg-meta`` row happens to be (usually the source chips row). They
get-or-create a dedicated ``.msg-meta.msg-actions`` row — the bubble's
LAST row, right-aligned by CSS (task 03) — so chips and images can
never push the buttons around.
Pins (the house source-pin pattern — no browser):
* ``getActionsRow(wrap)`` — the shared get-or-create helper: scoped to
the ``msg-actions`` class so it NEVER grabs the chips row (or any
other meta row), ``role=list`` + ``aria-label="Answer actions"``,
null without a ``.msg-body``;
* all FIVE appenders/notes route through ``getActionsRow(wrap)`` —
none of them reuses the first ``.msg-meta`` row anymore;
* ``markLastRetryable`` re-appends the Retry button into the actions
row and the phase-59 save-button re-append hack is GONE (the row's
row-level right-align replaces the per-button auto margins);
* the call-order audit (task 02 item 5): at every completed-bubble
render site the order is answer figure → sources → related → the
action appenders, so ``.msg-actions`` is the bubble's LAST row, and
the done branch's insertion order inside the row is Tune → Retry →
Save-as-doc.
The browser-level assertions (the right-aligned cluster, the row
order in the rendered DOM, the restore parity) are E2E-gated by
tests/e2e/test_chat_answer_clusters.py (task 04).
"""
from __future__ import annotations
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _fn_body(js: str, header: str) -> str:
"""The full source of the function whose header is *header* —
brace-matched from the first ``{`` OUTSIDE the parameter list
(paren depth 0, the house convention), so destructured parameters
are skipped (the pinned functions carry no string literal with a
stray brace)."""
start = js.index(header)
i = start
paren = 0
while i < len(js):
c = js[i]
if c == "(":
paren += 1
elif c == ")":
paren -= 1
elif c == "{" and paren == 0:
break
i += 1
depth = 0
for j in range(i, len(js)):
c = js[j]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError(f"unbalanced braces in {header!r}")
def _done_branch(js: str) -> str:
"""The SSE ``done`` branch — from the branch test to the next
``else if`` (flat block, the slice is exact)."""
start = js.index('ev.type === "done"')
return js[start : js.index('ev.type === "tool_result"', start)]
# ---------------------------------------------------------------------------
# getActionsRow — the shared get-or-create helper
# ---------------------------------------------------------------------------
def test_get_actions_row_gets_or_creates_the_dedicated_row() -> None:
"""``getActionsRow(wrap)`` find-or-creates the bubble's
``.msg-meta.msg-actions`` row: the query is scoped to the
``msg-actions`` class (NEVER the bare first ``.msg-meta`` — that
would grab the chips row), the created row is ``role=list`` +
``aria-label="Answer actions"`` (the ARIA convention of the other
meta rows), and it lands in ``.msg-body``. A missing ``.msg-body``
(the defensive pre-phase convention) returns null — the appenders
no-op."""
js = _js()
body = _fn_body(js, "function getActionsRow(wrap) {")
assert 'body.querySelector(".msg-meta.msg-actions")' in body, (
"the reuse query is scoped to the msg-actions class — the "
"helper must NEVER grab the chips row (or any other meta row)"
)
assert "document.querySelector" not in body and "body.querySelector(\".msg-meta\")" not in body
assert 'row.className = "msg-meta msg-actions";' in body
assert 'row.setAttribute("role", "list");' in body, (
"the row is an accessible list (the house meta-row convention)"
)
assert 'row.setAttribute("aria-label", "Answer actions");' in body, (
"the row's accessible name (WCAG — the row is never unnamed)"
)
assert "body.appendChild(row)" in body, "a new row lands in .msg-body"
assert 'wrap?.querySelector?.(".msg-body")' in body, (
"defensive .msg-body lookup (the stopped-note convention)"
)
assert "if (!body) return null" in body, "no .msg-body → null (appenders no-op)"
assert "return row" in body
def test_get_actions_row_is_the_shared_helper() -> None:
"""EXACTLY ONE getActionsRow (no per-appender copies of the
get-or-create logic), and the created row's class string appears
nowhere else in the file (the appenders only ever LOOK it up —
they never build a second actions row)."""
js = _js()
assert js.count("function getActionsRow(wrap) {") == 1, (
"exactly ONE getActionsRow — the shared helper"
)
assert js.count('row.className = "msg-meta msg-actions";') == 1, (
"the row is created in exactly ONE place — the helper; the "
"appenders never build a row themselves"
)
assert js.count('body.querySelector(".msg-meta.msg-actions")') == 1, (
"the reuse query lives in exactly ONE place — the helper"
)
# ---------------------------------------------------------------------------
# the five appenders/notes — all through getActionsRow
# ---------------------------------------------------------------------------
#: (function header, the one-per-bubble guard's class, the child class)
APPENDERS = [
("function appendTuneButton(wrap) {", ".tune-btn", "tune-btn"),
("function appendRetryButton(wrap) {", ".retry-btn", "retry-btn"),
(
"function appendSaveAsDocButton(wrap, markdown) {",
".save-as-doc-btn",
"save-as-doc-btn",
),
("function appendStoppedNote(wrap) {", ".stopped-note", "stopped-note"),
("function appendFailedNote(wrap, detail) {", ".failed-note", "failed-note"),
]
def test_all_action_appenders_join_the_actions_row() -> None:
"""All FIVE appenders/notes (Tune, Retry, Save-as-doc, Stopped,
Failed) route through ``getActionsRow(wrap)`` and no-op on its
null (no ``.msg-body``) — none of them reuses the FIRST
``.msg-meta`` row anymore (the pre-phase-130 soup-div behavior is
gone from every appender)."""
js = _js()
for header, guard, child in APPENDERS:
body = _fn_body(js, header)
assert "getActionsRow(wrap)" in body, f"{header} must use getActionsRow"
assert 'querySelector(".msg-meta")' not in body, (
f"{header} must not query the bare first .msg-meta row"
)
assert "if (!meta) return" in body, (
f"{header} must no-op when getActionsRow returns null"
)
assert f'querySelector("{guard}")' in body, (
f"{header} keeps its one-per-bubble guard"
)
assert f'"{child}"' in body, f"{header} keeps its child class"
def test_action_appenders_keep_the_listitem_aria() -> None:
"""ARIA is kept (the existing convention): the role=list check
stays on every appender and the child joins the row as a
listitem (the row is ALWAYS role=list — getActionsRow sets it —
so the check always applies)."""
js = _js()
for header, _, _ in APPENDERS:
body = _fn_body(js, header)
assert 'meta.getAttribute("role") === "list"' in body, (
f"{header} keeps the role=list check"
)
assert 'setAttribute("role", "listitem")' in body, (
f"{header} keeps the listitem join"
)
def test_tune_and_save_gates_are_unchanged() -> None:
"""The gates are untouched (no behavior change in completed work):
Tune is admin-gated (phase 16) and Save-as-doc is admin-gated AND
docs-repo-gated (phase 59) — both BEFORE any row work. Retry is
NOT admin-gated (owner-locked: every visitor)."""
js = _js()
tune = _fn_body(js, "function appendTuneButton(wrap) {")
assert 'if (!isAdmin) return' in tune
save = _fn_body(js, "function appendSaveAsDocButton(wrap, markdown) {")
assert "if (!isAdmin || !docsRepoConfigured) return" in save
retry = _fn_body(js, "function appendRetryButton(wrap) {")
assert "isAdmin" not in retry, "Retry stays NOT admin-gated"
def test_failed_note_detail_is_textcontent_only() -> None:
"""The ``failed-detail`` contract survives (task 02 item 3): the
error detail goes through ``textContent`` — no HTML from error
strings, ever."""
js = _js()
body = _fn_body(js, "function appendFailedNote(wrap, detail) {")
assert 'd.className = "failed-detail"' in body
assert "d.textContent = detail" in body, (
"the error string is NEVER innerHTML"
)
# The icon constant is the ONLY innerHTML (the static aria-hidden
# SVG) — the detail path never builds HTML (comments stripped —
# the "NEVER innerHTML" remark is not code).
code = "\n".join(
line for line in body.splitlines() if "//" not in line
)
assert code.count("innerHTML") == 1, (
"only the static FAILED_ICON innerHTML remains in the note"
)
# ---------------------------------------------------------------------------
# markLastRetryable — the rework (task 02 item 4)
# ---------------------------------------------------------------------------
def test_mark_last_retryable_moves_retry_into_the_actions_row() -> None:
"""``markLastRetryable`` keeps the phase-49 remove-then-append
shape (remove every rendered .retry-btn FIRST, then re-append on
the last brain bubble — only when its preceding user record
exists), but the re-append now lands in the bubble's .msg-actions
row (via appendRetryButton → getActionsRow). The phase-59
save-button re-append hack (the ``saveDocBtn`` block) is DELETED:
the row's row-level right-align (task 03) right-aligns the whole
cluster, and the DOM order inside the row is the insertion order.
The 'auto margins split the free space' rationale is gone with it."""
js = _js()
body = _fn_body(js, "function markLastRetryable() {")
assert 'querySelectorAll(".retry-btn")' in body
assert ".remove()" in body
assert body.index(".remove()") < body.index("appendRetryButton(lastBrainWrap)"), (
"the existing buttons are removed before the new one is appended"
)
assert "appendRetryButton(lastBrainWrap)" in body, (
"the re-append targets the last brain wrap (via getActionsRow)"
)
assert 'who !== "user"' in body, "the preceding-user-record guard stays"
assert "saveDocBtn" not in body, (
"the phase-59 save-button re-append hack is deleted"
)
assert "parentElement" not in body, (
"no DOM re-parenting of the save button anymore"
)
assert "auto margins" not in body, (
"the 'auto margins split the free space' rationale is gone"
)
# The hack had no other home in the file.
assert "saveDocBtn" not in js
# ---------------------------------------------------------------------------
# the call-order audit (task 02 item 5) — .msg-actions is the LAST row
# ---------------------------------------------------------------------------
def test_done_branch_appends_actions_last_in_insertion_order() -> None:
"""The live ``done`` branch (a completed brain bubble): the row
order is the DOM order — the answer figure, then the source
clusters (appendSources), then the related row, then the action
appenders — so the .msg-actions row is the bubble's LAST row.
Inside the row the insertion order is Tune → Retry (the
markLastRetryable re-append) → Save-as-doc."""
js = _js()
branch = _done_branch(js)
for marker in (
"appendSources(wrap, ev.sources);",
"appendRelated(wrap, ev.related);",
"appendTuneButton(wrap);",
"markLastRetryable();",
'appendSaveAsDocButton(wrap, finalText || acc || "…");',
):
assert marker in branch, f"the done branch must keep {marker!r}"
order = [
branch.index("appendSources(wrap, ev.sources);"),
branch.index("appendRelated(wrap, ev.related);"),
branch.index("appendTuneButton(wrap);"),
branch.index("markLastRetryable();"),
branch.index('appendSaveAsDocButton(wrap, finalText || acc || "…");'),
]
assert order == sorted(order), (
"done branch: sources → related → Tune → Retry → Save-as-doc "
"(the .msg-actions row is the bubble's LAST row — phase 130 "
"task 02; the row's inner order is the insertion order)"
)
# lastBrainWrap is set before the Retry re-append reads it.
assert branch.index("lastBrainWrap = wrap") < branch.index(
"markLastRetryable();"
)
def test_zero_frame_fallback_appends_actions_last() -> None:
"""The zero-frame-but-completed fallback bubble (the stream
settled with no events): a failed turn — Tune + the Failed note +
Retry (markLastRetryable) + Save-as-doc, all in the bubble's
.msg-actions row (the only row it has), the note BEFORE the Retry
re-append (the 'note + Retry' reading), the save button LAST in
the row (the phase-59 parity with the done path)."""
js = _js()
zero = js.index("if (!aborted && !wrap) {")
block = js[zero : js.index("} catch (err) {", zero)]
for marker in (
"appendTuneButton(fwrap);",
"appendFailedNote(fwrap, nothing);",
"markLastRetryable();",
"appendSaveAsDocButton(fwrap, fallback);",
):
assert marker in block, f"the fallback block must keep {marker!r}"
order = [
block.index("appendTuneButton(fwrap);"),
block.index("appendFailedNote(fwrap, nothing);"),
block.index("markLastRetryable();"),
block.index("appendSaveAsDocButton(fwrap, fallback);"),
]
assert order == sorted(order), (
"fallback: Tune → Failed note → Retry → Save-as-doc — the "
"actions row holds the whole cluster (phase 130 task 02)"
)
def test_stop_finalize_appends_actions_last() -> None:
"""The stop finalize (a stopped partial — the prime retry
candidate): Tune + the Stopped note + Retry (markLastRetryable),
all in the actions row — the stop path renders no sources or
related rows, so the actions row IS the bubble's only (and last)
row. The order is Tune → note → Retry (the 'note + Retry'
reading), unchanged from pre-phase (no save button — a stopped
partial is a note, not an answer)."""
js = _js()
catch = js.index("} catch (err) {")
stop = js.index('stoppedByUser || err?.name === "AbortError"', catch)
branch = js[stop : js.index("} else {", stop)]
for marker in (
"appendTuneButton(wrap);",
"appendStoppedNote(wrap);",
"markLastRetryable();",
):
assert marker in branch, f"the stop branch must keep {marker!r}"
order = [
branch.index("appendTuneButton(wrap);"),
branch.index("appendStoppedNote(wrap);"),
branch.index("markLastRetryable();"),
]
assert order == sorted(order), (
"stop: Tune → Stopped note → Retry (phase 130 task 02 — the "
"note + Retry in the actions row)"
)
assert "appendSaveAsDocButton" not in branch, (
"the stopped partial (note, not answer) still gets no save button"
)
def test_failed_funnel_note_and_retry_join_the_actions_row() -> None:
"""The phase-120 failure funnel (finalizeFailedTurn): the Failed
note is appended and the funnel ENDS with markLastRetryable — the
note + Retry in the failed bubble's actions row (the partial
keeps its streamed text; the zero-frame shape gets a plain
FAILED_TURN_TEXT bubble). Both shapes end retryable."""
js = _js()
header = (
"function finalizeFailedTurn(detail, "
"{ acc, thinking, tools, wrap, leavePartialIndex }) {"
)
body = _fn_body(js, header)
assert body.count("appendFailedNote(") == 2, (
"both funnel shapes add the in-bubble error note"
)
lines = [line for line in body.splitlines() if line.strip()]
assert lines[-1].strip() == "}" and lines[-2].strip().startswith(
"markLastRetryable();"
), (
"the funnel still ends with the markLastRetryable() call — the "
"Retry button lands on the failed bubble's actions row"
)
def test_restore_path_appends_actions_last() -> None:
"""The phase-14 restore path (renderStoredMessage): the answer
figure → sources → related → the action appenders (Tune /
Save-as-doc / the Stopped + Failed notes) — so the .msg-actions
row a restored bubble gets is its LAST row too, identical to the
live done path. The restore loop's after-the-loop
markLastRetryable() then lands the Retry button in that same row
(on the LAST restored brain bubble)."""
js = _js()
body = _fn_body(js, "function renderStoredMessage(m) {")
for marker in (
"appendSources(wrap, m.sources);",
"appendRelated(wrap, m.related);",
"appendTuneButton(wrap)",
"appendSaveAsDocButton(wrap, m.text);",
"appendStoppedNote(wrap);",
"appendFailedNote(wrap, m.error);",
"lastBrainWrap = wrap",
):
assert marker in body, f"the restore path must keep {marker!r}"
order = [
body.index("appendSources(wrap, m.sources);"),
body.index("appendRelated(wrap, m.related);"),
body.index("appendTuneButton(wrap)"),
body.index("appendSaveAsDocButton(wrap, m.text);"),
body.index("lastBrainWrap = wrap"),
]
assert order == sorted(order), (
"restore: sources → related → the action appenders → "
"lastBrainWrap (the after-the-loop markLastRetryable lands the "
"Retry in the same actions row — phase 130 task 02)"
)
+245
View File
@@ -0,0 +1,245 @@
"""Unit: the phase-130 (task 03, TODO L6) chat cluster rows — CSS.
The bubble's metadata reads as organized rows, top to bottom: the
answer text → the answer figure (phase 124, unchanged) → the source
chips row (text refs only) → the source images row (`.msg-images`,
phase 130 task 01) → the "Nearby docs" row (phase 113, unchanged) →
the actions row (`.msg-actions`, phase 130 task 02) — never one soup
div. The DOM is built by app.js (pinned by test_frontend_actions_cluster
and test_source_chip_quality); THIS module pins the row-level layout
that makes the clusters read as clusters:
* the actions row right-aligns its buttons as a cluster — the "bottom
right" is the row's ``justify-content: flex-end``, and the
per-button auto margins (the phase-49/59 ``.tune-btn`` /
``.retry-btn`` / ``.save-as-doc-btn`` margin mechanisms) are GONE;
* the images row wraps its figures with the chips row's rhythm (the
base ``.msg-meta`` wrap + 0.35rem gap + center align — pinned
explicitly so the two clusters cannot drift apart);
* the audit (task 03 item 3): the chips row and the related row are
untouched, the ``.source-image`` figure styles are untouched, and
the mobile squeeze pins nothing about the actions row left — the
cluster stays right-aligned at every width.
House source-pin pattern (no browser); the browser-level bounding-box
assertions are E2E-gated by tests/e2e/test_chat_answer_clusters.py
(task 04).
"""
from __future__ import annotations
import re
from pathlib import Path
STYLES_CSS = Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css"
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _block(css: str, selector: str) -> str:
"""The first ``<selector> { … }`` rule body — the house pin
pattern (the cluster selectors are unique in the sheet)."""
m = re.search(re.escape(selector) + r" \{([^}]*)\}", css)
assert m, f"styles.css must style {selector}"
return m.group(1)
def _media_block(css: str, query: str) -> str:
"""The body of the ``@media (<query>)`` block — the house non-
greedy pattern (the sheet indents the block's nested closing
braces, so the FIRST column-0 ``}`` is the block's own end)."""
pattern = "@media \\(" + re.escape(query) + r"\) \{([\s\S]*?)\n\}"
m = re.search(pattern, css)
assert m, f"{query} media query missing"
return m.group(1)
# ---------------------------------------------------------------------------
# the actions cluster — right-aligned by the ROW, no per-button margins
# ---------------------------------------------------------------------------
def test_actions_row_rule_right_aligns_the_cluster() -> None:
"""``.msg-meta.msg-actions`` right-aligns its contents — the TODO's
"bottom right" is the bubble's LAST row (the DOM order, phase 130
task 02) + this row-level justify. No positioning tricks: the row
keeps the base .msg-meta flex/wrap/rhythm, only the justify moves."""
css = _css()
assert re.search(r"\.msg-meta\.msg-actions \{", css), (
"the .msg-meta.msg-actions row rule is missing"
)
body = _block(css, ".msg-meta.msg-actions")
assert "justify-content: flex-end" in body, (
"the row's justify does the 'bottom right' (right edge)"
)
# Row-level only — no absolute/float/transform tricks.
for prop in ("position", "float", "transform", "margin", "padding"):
assert prop not in body, (
f"the actions row is a plain flex row ({prop} is a "
"positioning trick — the row order is DOM order)"
)
def test_no_per_button_auto_margins_remain() -> None:
"""The per-button auto margins (the pre-phase-130 mechanism the
markLastRetryable comment named) are gone from ALL THREE action
buttons — grep-confirmed: the buttons are plain flex items of the
actions row, and the row's justify does the alignment. (Completion
criterion: 'right-aligned by .msg-actions alone'.)"""
css = _css()
for selector in (".tune-btn", ".retry-btn", ".save-as-doc-btn"):
body = _block(css, selector)
assert "margin-left: auto" not in body, (
f"{selector}: the per-button auto margin must be gone — "
"the .msg-actions row's justify right-aligns the cluster"
)
assert "margin-inline-start: auto" not in body, (
f"{selector}: the per-button auto margin must be gone"
)
assert "margin-right: auto" not in body and "margin-inline-end: auto" not in body
def test_button_family_is_untouched() -> None:
"""Removing the auto margins is the ONLY change to the button
rules — the phase-49/59 visual family (pill, ≥44px, line border,
ink-soft palette, brand/neutral hovers, 14px glyph) is intact."""
css = _css()
for selector in (".tune-btn", ".retry-btn", ".save-as-doc-btn"):
body = _block(css, selector)
for prop in (
"display: inline-flex",
"min-height: 44px",
"padding: 0.35rem 0.8rem",
"border-radius: 999px",
"border: 1px solid var(--line)",
"color: var(--ink-soft)",
"font-size: 0.82rem",
"cursor: pointer",
):
assert prop in body, f"{selector} must keep the family ({prop})"
assert f"{selector} svg {{ width: 14px; height: 14px" in css
assert f"{selector}:hover" in css
# ---------------------------------------------------------------------------
# the images cluster — wraps like the chips row
# ---------------------------------------------------------------------------
def test_images_row_rule_wraps_like_the_chips_row() -> None:
"""``.msg-meta.msg-images`` pins the chips row's rhythm EXPLICITLY
(wrap + the base .msg-meta 0.35rem gap + center align) — no new
spacing values (task 03: 'match the EXISTING .msg-meta gap/align
values'). A future base-rule change cannot drift the two clusters
apart."""
css = _css()
assert re.search(r"\.msg-meta\.msg-images \{", css), (
"the .msg-meta.msg-images row rule is missing"
)
body = _block(css, ".msg-meta.msg-images")
assert "flex-wrap: wrap" in body, "the figures wrap (many image refs)"
assert "gap: 0.35rem" in body, (
"the images row's gap MUST equal the chips row's gap "
"(the base .msg-meta 0.35rem — no invented spacing)"
)
assert "align-items: center" in body, "the row aligns like the chips row"
def test_base_msg_meta_rhythm_is_unchanged() -> None:
"""The base ``.msg-meta`` rule (the chips row, the related row, and
every meta row's rhythm) is untouched: flex + wrap + 0.35rem gap +
center align + the soft-ink 0.75rem text."""
css = _css()
body = _block(css, ".msg-meta")
for prop in (
"font-size: 0.75rem",
"color: var(--ink-soft)",
"display: flex",
"flex-wrap: wrap",
"gap: 0.35rem",
"align-items: center",
):
assert prop in body, f"the base .msg-meta rhythm must keep {prop}"
# ---------------------------------------------------------------------------
# the audit (task 03 item 3) — neighbors untouched
# ---------------------------------------------------------------------------
def test_chips_row_is_untouched() -> None:
"""The chips row's look is the completed phase-26/113 surface —
mono pill, brand-soft fill, line border, nowrap ellipsis (the
min-width: 0 escape). Phase 130 only removed the buttons from this
row's neighbors, never the chip's styling."""
css = _css()
body = _block(css, ".source-chip")
for prop in (
"font-family: var(--mono)",
"background: var(--brand-soft)",
"border: 1px solid var(--line)",
"border-radius: 999px",
"white-space: nowrap",
"min-width: 0",
"text-overflow: ellipsis",
):
assert prop in body, f".source-chip must keep {prop}"
assert ".source-chip:hover" in css
def test_related_row_is_untouched() -> None:
"""The phase-113 "Nearby docs" row keeps its de-emphasized look
(dashed border, transparent fill, muted mono links — never a
citation) and still joins the .msg-meta family."""
css = _css()
body = _block(css, ".related-doc")
for prop in (
"border: 1px dashed var(--line)",
"background: transparent",
"font-family: var(--mono)",
"white-space: nowrap",
):
assert prop in body, f".related-doc must keep {prop}"
assert ".related-docs-label" in css
assert "Nearby docs" not in css # the label text lives in app.js, not CSS
def test_source_image_figure_is_untouched() -> None:
"""The ``.source-image`` figure (phase 122) was already sized for a
row context — the 96px contain img on the theme's surface, the
clamped muted caption. Phase 130 moves it to its OWN row, not to a
new scale."""
css = _css()
assert "max-height: 96px" in _block(css, ".source-image-img"), (
"the compact source figure keeps its 96px row-scale cap"
)
assert "object-fit: contain" in _block(css, ".source-image-img")
assert "-webkit-line-clamp: 2" in _block(css, ".source-image-caption")
# The answer figure (phase 124) is a DIFFERENT scale — untouched.
assert "max-height: 320px" in _block(css, ".answer-image-img")
def test_mobile_pins_the_actions_row_left_nowhere() -> None:
"""The mobile squeeze (≤640px) may keep the ≥44px touch floor but
must NOT pin the actions row left or restyle it: the cluster stays
right-aligned at every width (the row's justify is width-agnostic,
the .msg-body flex gap stacks the rows)."""
css = _css()
mobile = _media_block(css, "max-width: 640px")
for selector in (".tune-btn", ".retry-btn", ".msg-actions", ".msg-images"):
for m in re.finditer(re.escape(selector) + r" \{([^}]*)\}", mobile):
body = m.group(1)
assert "justify-content" not in body, (
f"{selector}: the mobile block must not re-justify the "
"cluster (the actions row stays right-aligned)"
)
assert "margin-left" not in body and "margin-inline" not in body, (
f"{selector}: the mobile block must not re-margin the "
"cluster (no left pin, no margin reset)"
)
assert "position" not in body
# The mobile squeeze's only tune/retry rules are the 44px floors
# (pre-existing, untouched by this phase).
assert ".tune-btn { min-height: 44px; }" in mobile
assert ".retry-btn { min-height: 44px; }" in mobile
+7 -3
View File
@@ -84,12 +84,16 @@ def _fn_body(js: str, name: str) -> str:
def test_append_failed_note_exists_and_guards_dedup() -> None:
"""``appendFailedNote(wrap, detail)`` exists and mirrors
``appendStoppedNote``: it reuses the ``.msg-meta`` row and adds at
``appendStoppedNote``: it joins the bubble's ``.msg-actions`` row
(getActionsRow — phase 130 task 02, ASSUMPTION A7: the notes are
answer-status markers, the actions row is their home) and adds at
most ONE ``.failed-note`` per bubble (the duplicate guard)."""
js = _js()
body = _fn_body(js, "appendFailedNote")
assert '.msg-body' in body, "the note must land in the bubble's .msg-body"
assert ".msg-meta" in body, "the note rides the existing .msg-meta row"
assert "getActionsRow(wrap)" in body, (
"the note joins the dedicated .msg-actions row (getActionsRow — "
"phase 130 task 02), never the first .msg-meta row"
)
assert '.failed-note' in body
assert (
'if (meta.querySelector(".failed-note")) return;' in body
+35 -23
View File
@@ -313,18 +313,22 @@ def test_stop_branch_keeps_partial_and_persists_stopped() -> None:
def test_stopped_note_helper_and_restore_path() -> None:
"""appendStoppedNote: reuses/creates the .msg-meta row exactly like
appendTuneButton (role=list → the span joins as a listitem), one
.stopped-note per bubble — the aria-hidden stop-glyph SVG + the
"Stopped" text (the accessible meaning). The restore path renders it
for records with `m.stopped` (phase-14 optional-field convention —
no version bump)."""
"""appendStoppedNote: joins the bubble's ACTIONS row (getActionsRow
— phase 130 task 02, ASSUMPTION A7: the notes are answer-status
markers, the actions row is their home; the role=list row joins the
span as a listitem so ARIA stays valid), one .stopped-note per
bubble — the aria-hidden stop-glyph SVG + the "Stopped" text (the
accessible meaning). The restore path renders it for records with
`m.stopped` (phase-14 optional-field convention — no version
bump)."""
js = _js()
fn = js.find("function appendStoppedNote")
assert fn != -1, "appendStoppedNote must exist"
body = js[fn : js.find("\n}\n", fn)]
assert 'querySelector(".msg-meta")' in body, "reuses the meta row when it exists"
assert 'className = "msg-meta"' in body, "creates it otherwise"
assert "getActionsRow(wrap)" in body, (
"the note joins the dedicated .msg-actions row (getActionsRow — "
"phase 130 task 02), never the first .msg-meta row"
)
assert 'className = "stopped-note"' in body
assert 'querySelector(".stopped-note")' in body, "one note per bubble"
assert 'note.setAttribute("role", "listitem")' in body
@@ -443,19 +447,22 @@ def test_run_turn_is_the_extracted_turn_handler() -> None:
def test_retry_button_is_not_admin_gated_and_one_per_bubble() -> None:
"""appendRetryButton: the house appendTuneButton pattern — reuses the
.msg-meta row when it exists (role=list → the button joins as a
listitem so ARIA stays valid), creates it otherwise, one .retry-btn
per bubble, the aria-hidden redo glyph + the "Retry" text (the
accessible name). NOT admin-gated — unlike appendTuneButton, chat is
public and every visitor gets the redo (owner-locked)."""
"""appendRetryButton: the house pattern through getActionsRow
(phase 130 task 02 — the button joins the bubble's dedicated
.msg-actions row; the role=list row joins it as a listitem so ARIA
stays valid), one .retry-btn per bubble, the aria-hidden redo glyph
+ the "Retry" text (the accessible name). NOT admin-gated — unlike
appendTuneButton, chat is public and every visitor gets the redo
(owner-locked)."""
js = _js()
fn = js.find("function appendRetryButton")
assert fn != -1, "appendRetryButton must exist"
body = js[fn: js.find("\n}\n", fn)]
assert "isAdmin" not in body, "Retry is NOT admin-gated (owner-locked: all visitors)"
assert 'querySelector(".msg-meta")' in body, "reuses the meta row when it exists"
assert 'className = "msg-meta"' in body, "creates it otherwise"
assert "getActionsRow(wrap)" in body, (
"the button joins the dedicated .msg-actions row (getActionsRow — "
"phase 130 task 02), never the first .msg-meta row"
)
assert 'className = "retry-btn"' in body
assert 'querySelector(".retry-btn")' in body, "one Retry button per bubble"
assert 'btn.setAttribute("role", "listitem")' in body, ("role=list → listitem")
@@ -584,12 +591,14 @@ def test_last_brain_wrap_tracking_and_restore() -> None:
def test_retry_btn_css_is_the_tune_family() -> None:
"""Phase 49 styling: the .retry-btn pill is the exact .tune-btn
visual family (same size/spacing/min-height, right-aligned after
the source chips, 14px glyph) so the two meta actions read as a
pair — with the neutral ink-soft → ink hover (Tune keeps the brand
pair). Contrast: ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on
--brand-soft ~15.7:1 — all AA. The mobile squeeze keeps the >=44px
floor."""
visual family (same size/spacing/min-height, 14px glyph) so the
meta actions read as a pair — with the neutral ink-soft → ink
hover (Tune keeps the brand pair). The right-align moved to the
ROW in phase 130 (the .msg-meta.msg-actions justify — the button
is a plain flex item with NO per-button auto margin; the
cluster-level pin lives in test_frontend_cluster_css). Contrast:
ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on --brand-soft
~15.7:1 — all AA. The mobile squeeze keeps the >=44px floor."""
css = _css()
block = re.search(r"\.retry-btn \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .retry-btn"
@@ -597,7 +606,6 @@ def test_retry_btn_css_is_the_tune_family() -> None:
for prop in (
"display: inline-flex",
"min-height: 44px",
"margin-left: auto",
"padding: 0.35rem 0.8rem",
"border-radius: 999px",
"border: 1px solid var(--line)",
@@ -606,6 +614,10 @@ def test_retry_btn_css_is_the_tune_family() -> None:
"cursor: pointer",
):
assert prop in body, f".retry-btn must keep the .tune-btn family ({prop})"
assert "margin-left: auto" not in body and "margin-inline-start: auto" not in body, (
"phase 130: the per-button auto margin is gone — the row's "
".msg-actions justify right-aligns the cluster"
)
assert re.search(r"\.retry-btn svg \{ width: 14px; height: 14px", css), (
"the redo glyph rides the 14px meta-row size"
)
+94 -21
View File
@@ -51,12 +51,19 @@ image doc prefacing the description with the pinned
``IMAGE_DOC_MARKER`` line (header + date line byte-identical, the
cap-truncation case marker-before-cut, a text doc's result
byte-identical); and the house-style frontend source contracts — the
chat sources block's inline image figure (both the citation chips and
the related row, gated on ``s.image_url``) reads ``image_url``, sets
chat sources block's inline image figure reads ``image_url``, sets
``alt`` + the visible caption to the document's summary (fetched from
the content endpoint the chip's modal already uses — the frame
carries no summary), and collapses to the plain chip on an image
load error (never a broken-image icon). The SSE-level byte pin sits in
carries no summary), and becomes the IN-PLACE CHIP FALLBACK on an
image load error (phase 130 task 01: the figure is replaced, in
place, with a plain chip for the same document — never a broken-image
icon; the pre-phase-130 rule collapsed by removing the figure, the
chip staying). Phase 130 (task 01) also re-partitions the citation
block: the chips row holds the NON-image refs only (no chip for an
image doc — its figure IS the citation) and a second
``.msg-meta.msg-images`` row holds the figures in ref order (the
related row keeps its own interleave, unchanged — ASSUMPTION A4).
The SSE-level byte pin sits in
``tests/integration/test_chat_api.py``; the E2E rendering scenario is
task 06's isolated story suite.
"""
@@ -1342,16 +1349,23 @@ def test_read_of_image_doc_over_the_cap_truncates_after_the_marker(db) -> None:
# --- the chat sources block: the inline image (house-style frontend)
def test_chat_sources_figure_reads_image_url_sets_alt_and_collapses_on_error() -> None:
def test_chat_sources_figure_reads_image_url_sets_alt_and_falls_back_in_place() -> None:
"""House-style source contract (app.js appendSourceImageFigure —
task 05's "shown in the chat nicely" renderer): the figure's
``<img>`` comes from the ref's ``image_url``; ``alt`` is set
(title first, then the SUMMARY once the content fetch settles —
the frame carries no summary, image_url is its only new field)
and so is the VISIBLE caption; a FAILED image load removes the
whole figure — it collapses to the plain chip, never a
broken-image icon. Properties only (src/alt/textContent) — the
figure builds no innerHTML from document-derived data."""
and so is the VISIBLE caption. Phase 130 (task 01, ASSUMPTION A3)
REPLACES the old collapse-to-chip rule (``fig.remove()`` — the
chip stayed) with the IN-PLACE CHIP FALLBACK: a FAILED image load
replaces the figure, in place (``fig.replaceWith(chip)``), with a
plain ``.source-chip`` anchor for the same document (the same
``documentUrl`` href, the same left-click → modal, the label as
text) — there is no chip for image docs anymore, so the figure
itself becomes the citation; never a broken-image icon. The fetch
summary swap is untouched (its ``!fig.isConnected`` guard makes it
a no-op after the swap). Properties only (src/alt/textContent) —
the figure builds no innerHTML from document-derived data."""
js = APP_JS.read_text(encoding="utf-8")
body = _function_body(js, "function appendSourceImageFigure(meta, s) {")
assert "img.src = s.image_url" in body, "the <img> must come from s.image_url"
@@ -1360,10 +1374,34 @@ def test_chat_sources_figure_reads_image_url_sets_alt_and_collapses_on_error() -
assert "caption.textContent = s.title || label" in body # visible caption: title
assert "caption.textContent = summary" in body # …then the summary
assert 'img.addEventListener("error"' in body, "the error handler must exist"
assert "fig.remove();" in body, "a failed load collapses to the plain chip"
assert "fig.remove()" not in body, (
"the phase-122 collapse rule is RETIRED — the figure is never "
"simply removed (there is no chip left behind to stay)"
)
# Phase 130 (task 01): the in-place chip fallback — the figure is
# replaced with a plain .source-chip anchor for the same document.
assert "fig.replaceWith(chip);" in body, (
"a failed load replaces the figure IN PLACE with its chip"
)
assert 'chip.className = "source-chip";' in body, (
"the fallback is a PLAIN citation chip"
)
assert 'chip.setAttribute("role", "listitem");' in body # ARIA stays valid
assert 'chip.href = documentUrl(s.source, s.path, "/");' in body, (
"the fallback chip keeps the chip's /document.html href"
)
assert "openDocumentModal(s.source, s.path, chip);" in body, (
"the fallback chip keeps the left-click → same-page modal"
)
assert "e.preventDefault();" in body # no new tab (phase 26)
assert "chip.textContent = label;" in body, ("the label is the chip's text")
assert "chip.title = label;" in body # full path as the native tooltip
assert "fetchContentSummary(s.source, s.path)" in body, (
"the frame carries no summary — the figure fetches it"
)
assert "if (!fig.isConnected) return;" in body, (
"the summary swap is a no-op once the figure was replaced"
)
code = re.sub(r"//.*", "", re.sub(r"/\*.*?\*/", "", body, flags=re.S))
assert "innerHTML" not in code, "no innerHTML from document-derived data"
# The figure reuses the chip's navigation (phase 26 contract):
@@ -1371,20 +1409,55 @@ def test_chat_sources_figure_reads_image_url_sets_alt_and_collapses_on_error() -
assert "openDocumentModal(s.source, s.path, fig);" in body
def test_chat_sources_and_related_rows_gate_the_figure_on_image_url() -> None:
"""Both chat source surfaces (the citation chips AND the related
row — the frame's two ref tiers, built by the same server helper)
render the figure ONLY when the ref carries ``image_url``: text
refs (and every pre-phase saved ref) add zero new DOM — the
renderer stays byte-identical for them. The chip's text +
affordance stay (the figure is additive, not a replacement), and
the summary fetch targets the SAME content endpoint the chip's
modal already uses (failing soft to null — the title stands)."""
def test_chat_sources_splits_the_chips_row_from_the_images_row() -> None:
"""Phase 130 (task 01, TODO L6): ``appendSources`` stops
interleaving figures after chips — it PARTITIONS the refs on the
existing image-doc signal (the ``image_url`` presence, phase 122)
and builds two clusters: the chips row from the NON-image refs
ONLY (no chip for an image doc — the figure IS the citation) and
a second ``.msg-meta.msg-images`` row (``role=list`` +
``aria-label="Source images"``) holding one figure per image ref
in ref order. Each row is built only when its refs are non-empty
(image-only citations get no chips row; text-only turns render
the pre-phase-130 single row). The clipped-chip ``aria-label``
pass stays, over the chips row only. The related row is a
separate tier and is NOT reorganized (ASSUMPTION A4 — its own
chip/figure interleave is untouched), and the summary fetch
targets the SAME content endpoint the chip's modal already uses
(failing soft to null — the title stands)."""
js = APP_JS.read_text(encoding="utf-8")
src = _function_body(js, "function appendSources(wrap, sources) {")
assert 'if (s.image_url) appendSourceImageFigure(meta, s);' in src
assert 'chip.className = "source-chip";' in src, "the chip stays (additive)"
# The partition (the `image_url` presence is the image-doc signal):
assert "const imageRefs = sources.filter((s) => s.image_url);" in src
assert "const textRefs = sources.filter((s) => !s.image_url);" in src
# Cluster 1 — the chips row, text refs only, skipped when empty:
assert "if (textRefs.length) {" in src, (
"the chips row is skipped when there are no text refs "
"(an image-only citation still gets its images row)"
)
assert "for (const s of textRefs) {" in src, (
"one chip per NON-image ref — an image ref contributes NO chip"
)
assert 'meta.className = "msg-meta";' in src
assert 'meta.setAttribute("aria-label", "Sources");' in src # ARIA name kept
assert 'chip.className = "source-chip";' in src
assert "chip.textContent = label" in src
# The clipped-chip aria-label pass stays, over the chips row only:
assert "chip.scrollWidth > chip.clientWidth" in src
# Cluster 2 — the images row, image refs only, absent when empty:
assert "if (imageRefs.length) {" in src, (
"the images row is built only when image refs exist"
)
assert 'images.className = "msg-meta msg-images";' in src
assert 'images.setAttribute("role", "list");' in src
assert 'images.setAttribute("aria-label", "Source images");' in src
assert "for (const s of imageRefs) appendSourceImageFigure(images, s);" in src, (
"one figure per image ref, in ref order, no chips in the row"
)
# The phase-122 interleave is gone from appendSources (the figure
# no longer rides right after a chip in the chips row):
assert "if (s.image_url) appendSourceImageFigure(meta, s);" not in src
# The related tier is NOT reorganized (ASSUMPTION A4):
rel = _function_body(js, "function appendRelated(wrap, related) {")
assert 'if (s.image_url) appendSourceImageFigure(row, s);' in rel
fetch = _function_body(js, "function fetchContentSummary(source, path) {")
+42 -20
View File
@@ -139,8 +139,9 @@ def test_app_js_button_gates_on_admin_and_configured() -> None:
"""The single guard: admin (the whoami gate Tune uses) AND
docs_repo_configured — otherwise the function injects NOTHING
(anonymous, or unconfigured admin, or deflected scope — same as
Tune). One button per bubble; the .msg-meta row is reused (or
created plain) and a role=list row gets a listitem button (ARIA)."""
Tune). One button per bubble; the button joins the bubble's
.msg-actions row (getActionsRow — phase 130 task 02; the role=list
row gets a listitem button, ARIA)."""
js = _text(APP_JS)
fn_idx = js.find("function appendSaveAsDocButton(wrap, markdown) {")
assert fn_idx != -1, "appendSaveAsDocButton missing"
@@ -354,23 +355,36 @@ def test_app_js_post_payload_and_navigation() -> None:
assert "Couldn't save the answer as a doc" in fn_body
def test_app_js_retry_landing_keeps_save_rightmost() -> None:
"""markLastRetryable re-appends the save button AFTER the Retry
button lands on the same (last) bubble — the auto-margined buttons
split the row's free space between them, so DOM order decides the
right edge: "Save as doc" stays the bottom-right action even on the
last bubble (which also carries Retry)."""
def test_app_js_retry_landing_has_no_save_reappend_hack() -> None:
"""Phase 130 (task 02): markLastRetryable re-appends the Retry
button into the last bubble's .msg-actions row (appendRetryButton →
getActionsRow) and NO LONGER re-appends the save button after it —
the row's row-level right-align (phase 130 task 03) right-aligns
the WHOLE cluster, and the DOM order inside the row is the
insertion order (the live call sites append Tune → Retry →
Save-as-doc per their gates). The phase-59 "auto margins split the
free space" hack is gone with the per-button auto margins it worked
around."""
js = _text(APP_JS)
fn_idx = js.find("function markLastRetryable() {")
assert fn_idx != -1
fn_end = js.find("/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right", fn_idx)
fn_body = js[fn_idx:fn_end]
assert "appendRetryButton(lastBrainWrap);" in fn_body
assert 'lastBrainWrap.querySelector(".save-as-doc-btn")' in fn_body
# "saveDocBtn" — NOT "saveBtn": phase 55 pins the Save pill's
# identifier gone from app.js (substring), so the local stays distinct.
assert "saveDocBtn.parentElement.appendChild(saveDocBtn)" in fn_body
assert "saveBtn" not in _text(APP_JS), (
assert "appendRetryButton(lastBrainWrap);" in fn_body, (
"the Retry button is re-appended to the last brain bubble"
)
assert "saveDocBtn" not in fn_body, (
"the phase-59 save-button re-append hack is gone — the actions "
"row's right-align (phase 130 task 03) replaces it"
)
assert "parentElement" not in fn_body, (
"no DOM re-parenting of the save button anymore"
)
# "saveDocBtn" is gone from the WHOLE file (the hack had no other
# home); "saveBtn" (the phase-55 retired Save pill's identifier)
# stays absent too.
assert "saveDocBtn" not in js
assert "saveBtn" not in js, (
"the phase-55 pin: no saveBtn identifier in app.js"
)
@@ -381,16 +395,24 @@ def test_app_js_retry_landing_keeps_save_rightmost() -> None:
def test_styles_css_save_as_doc_btn_is_right_aligned() -> None:
""".save-as-doc-btn exists, carries the bottom-right declaration
(margin-inline-start: auto) and the .tune-btn visual family (pill,
>=44px target, line border, ink-soft palette); :focus-visible is
the global rule, the hover rule is per-class."""
""".save-as-doc-btn exists in the .tune-btn visual family (pill,
>=44px target, line border, ink-soft palette) with NO per-button
auto margin — phase 130 moved the bottom-right requirement to the
ROW (the .msg-meta.msg-actions justify right-aligns the whole
actions cluster; the cluster-level pin lives in
test_frontend_cluster_css). :focus-visible is the global rule, the
hover rule is per-class."""
css = _text(STYLES_CSS)
m = re.search(r"\.save-as-doc-btn \{[^}]*\}", css)
assert m, "the .save-as-doc-btn rule is missing"
block = m.group(0)
assert "margin-inline-start: auto;" in block, (
"the bottom-right requirement lives on the button's class"
assert "margin-inline-start: auto" not in block and "margin-left: auto" not in block, (
"phase 130: the per-button auto margin is gone — the row's "
".msg-actions justify does the bottom right"
)
row = re.search(r"\.msg-meta\.msg-actions \{[^}]*\}", css)
assert row and "justify-content: flex-end" in row.group(0), (
"the bottom-right requirement now lives on the .msg-actions row"
)
assert "min-height: 44px;" in block # WCAG touch target (the family)
assert "border-radius: 999px;" in block
+33 -19
View File
@@ -223,26 +223,35 @@ def test_append_related_row_contract() -> None:
# ---------- task 02: wiring — the done frame and the restore path ----------
def test_done_handler_calls_append_related_last() -> None:
def test_done_handler_appends_related_above_the_actions_row() -> None:
"""The done-frame handler calls ``appendRelated(wrap, ev.related)``
— and appends the row LAST among the bubble's meta rows: AFTER
``markLastRetryable()`` (the appendTuneButton / appendSaveAsDoc /
appendRetryButton claimers all take the FIRST ``.msg-meta`` row,
so a deflected turn with related docs — no citation row — gets its
OWN meta row for the buttons instead of actions joining the
related row). The call sits after the Retry claim in the branch."""
— and appends the row BELOW the citation clusters and ABOVE the
actions row (phase 130 task 02): the action appenders (appendTune
Button → markLastRetryable's Retry → appendSaveAsDocButton) FOLLOW
it in the branch, so the get-or-created ``.msg-actions`` row is the
bubble's LAST row (the bottom-right cluster — DOM order is visual
order). The getActionsRow scoping keeps the buttons out of the
related row either way: a deflected turn with related docs gets its
OWN actions row, never a button in the related row."""
branch = _done_branch(_app_js())
assert "appendRelated(wrap, ev.related);" in branch, (
"the done handler must render the related tier (it arrives in "
"ev.related — on a deflected turn ev.sources is empty and the "
"weak hits live here, row only, zero chips)"
)
assert branch.index("appendRelated(wrap, ev.related);") > branch.index(
"markLastRetryable();"
assert branch.index("appendRelated(wrap, ev.related);") < branch.index(
"appendTuneButton(wrap);"
), (
"appendRelated must run AFTER the meta-action claimers (last "
"meta row) — a deflected turn's tune/retry buttons must land in "
"their own row, never in the related row"
"appendRelated must run BEFORE the action appenders — the "
".msg-actions row is the bubble's LAST row (phase 130 task 02)"
)
# The insertion order inside the row: Tune → Retry → Save-as-doc
# (markLastRetryable re-appends the Retry button between them).
assert branch.index("appendTuneButton(wrap);") < branch.index(
"markLastRetryable();"
) < branch.index('appendSaveAsDocButton(wrap, finalText || acc || "…");'), (
"the actions row's DOM order is the insertion order: Tune, "
"Retry, Save-as-doc (phase 130 task 02)"
)
@@ -266,25 +275,30 @@ def test_done_handler_persists_related_with_the_turn() -> None:
)
def test_restore_path_calls_append_related() -> None:
def test_restore_path_appends_related_above_the_actions_row() -> None:
"""The phase-14 restore path (``renderStoredMessage`` — used by
BOTH the localStorage restore and the /?chat=<id> saved-chat boot
load) re-renders the related row from the stored payload when it
carries ``related``; a pre-phase record without the field
restores exactly as today (appendRelated no-ops — no row). The
call sits after the meta-row claimers, exactly like the live
done path, so restored buttons never join the related row."""
call sits BEFORE the action appenders (Tune / Save-as-doc / notes —
plus the restore loop's after-the-loop markLastRetryable Retry),
so the .msg-actions row is the restored bubble's LAST row too —
identical order to the live done path (phase 130 task 02); the
getActionsRow scoping keeps restored buttons out of the related
row."""
body = _function_body(_app_js(), "function renderStoredMessage(m) {")
assert "appendRelated(wrap, m.related);" in body, (
"the restore path must render the related tier from the stored "
"payload (pre-phase records carry no `related` → no row, "
"graceful)"
)
assert body.index("appendRelated(wrap, m.related);") > body.index(
"appendStoppedNote(wrap);"
assert body.index("appendRelated(wrap, m.related);") < body.index(
"appendTuneButton(wrap)"
), (
"the related row appends LAST (after the claimers) on the "
"restore path too — identical order to the live done path"
"the related row appends BEFORE the action appenders on the "
"restore path too (the actions row is the bubble's LAST row) — "
"identical order to the live done path (phase 130 task 02)"
)