phase: 170_edit_bubble_sizing
All verification complete. Final report: **Phase 170 — final verification pass (all 3 tasks were in `complete/`):** **Verified implemented:** - Editor renders inside the bubble: `bubble.replaceChildren(form)`, `display:none` rule deleted, `form.style.minWidth` seeded with pre-edit width, `restoreBubble()` (markdown + image re-prepend) before `closeEditor()` on Cancel/Escape, `closeEditor()` reordered before submit re-render - Label: `editSaveBtn.textContent = "Send"` (app.js L1241) **Gates (all 100% green):** - `uv run pytest` → 4066 passed - `uv run pytest --cov=app --cov-report=term-missing` → 99% on `app/` (gate >90%) - `uv run ruff check . && uv run pyright` → clean (0 errors, 0 warnings) - `uv run pytest tests/e2e/test_edit_bubble_sizing.py -v --no-cov` (in isolation) → 6/6 passed - Regression set in isolation: `test_edit_and_resend.py` 7✓, `test_retry_answer.py` 4✓, `test_failed_turn_retry.py` 4✓, `test_chat_persistence.py` 4✓ **Completion criteria:** in-bubble editor + width pin ✅ · "Send" label + cancel restore + trim re-ask + "Question too long" driver case ✅ · suite/coverage/lint ✅ · E2E + regression isolation ✅ · phase dir moved to `complete/` ✅ (atomic commit is the harness's to make — changes left in the working tree per protocol) **Documentation:** README.md reviewed in full — edit section already aligned to phase 170 (in-bubble editor + "Send"), pages table matches actual routes, all 29 `.env.example` vars documented, setup commands accurate; AGENTS.md reviewed — all referenced files/commands exist. Both verified consistent — no changes needed. **Notable decisions:** none — verification pass only, no code changes required. **Next pending phase:** `171_admin_settings_layout`
This commit is contained in:
@@ -26,10 +26,10 @@ The contract under test (phase 159, owner-locked 2026-10-05):
|
||||
earlier bubbles are never editable.
|
||||
* The Edit opens an INLINE editor in the bubble (the ``.tune-form``
|
||||
family — the textarea prefilled with the question,
|
||||
``maxlength`` = the 4000 cap, Save/Cancel, a ``role=alert`` error
|
||||
``maxlength`` = the 4000 cap, Send/Cancel, a ``role=alert`` error
|
||||
line); Cancel/``Escape`` restore the bubble and return focus to the
|
||||
toggle.
|
||||
* Save = REPLACE + TRUNCATE + RE-ASK (the phase-49 redo-in-place rule,
|
||||
* Send = REPLACE + TRUNCATE + RE-ASK (the phase-49 redo-in-place rule,
|
||||
one level up): the edited text replaces the record in place, every
|
||||
record after it is dropped (DOM + ``bor.chat.v1`` + the saved chat
|
||||
row), and a fresh answer streams from the edited text with
|
||||
@@ -48,7 +48,7 @@ Test → contract mapping:
|
||||
|
||||
1. ``test_edit_last_question_reasks_in_place`` — send, grounded answer
|
||||
(ONE old brain bubble), the user bubble carries Edit; the editor
|
||||
prefills the EXACT question; append " in one line" + Save: exactly
|
||||
prefills the EXACT question; append " in one line" + Send: exactly
|
||||
ONE user bubble (the edited text), the old answer bubble is gone,
|
||||
the new answer streams with the marker, ``bor.chat.v1`` holds
|
||||
``[user(edited), brain]`` with NO duplicate question, and the
|
||||
@@ -76,7 +76,7 @@ Test → contract mapping:
|
||||
this identical settle path, its exact detail string is
|
||||
real-endpoint-only, see the module docstring); Edit exists on the
|
||||
question BESIDE the failed bubble's Retry; trimming (the trigger
|
||||
stripped) + Save streams the grounded answer (the failed record is
|
||||
stripped) + Send streams the grounded answer (the failed record is
|
||||
part of the dropped tail — ``failed: true`` never survives).
|
||||
6. ``test_edit_is_inert_while_the_turn_runs`` — the ``write a long
|
||||
answer`` trigger (~8 s): NO ``.edit-btn`` while the turn runs
|
||||
@@ -252,7 +252,7 @@ def _poll_saved_messages(
|
||||
phase-55 upserts are fire-and-forget — there is no status line to
|
||||
wait on; the ``#send-label`` settle only proves the LAST save
|
||||
point fired, not that its PUT landed). The row's title keeps the
|
||||
FIRST question on re-Save (title-keep), so the edit's row is the
|
||||
FIRST question on re-Send (title-keep), so the edit's row is the
|
||||
same row, polled by the original title."""
|
||||
deadline = time.monotonic() + 15
|
||||
last: list[dict] | None = None
|
||||
@@ -338,7 +338,7 @@ def _save_editor(page: Page, new_text: str) -> None:
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1 — the flow: send → Edit (prefilled) → trim + Save → the old answer
|
||||
# 1 — the flow: send → Edit (prefilled) → trim + Send → the old answer
|
||||
# is gone, the new one streams, the record + the row are truncated
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -380,7 +380,7 @@ def test_edit_last_question_reasks_in_place(
|
||||
assert MOCK_ANSWER_MARKER in stored["messages"][1]["text"]
|
||||
|
||||
# The auto-saved row (polled — the title keeps the FIRST question
|
||||
# on re-Save): the truncated shape [user(edited), brain], matching
|
||||
# on re-Send): the truncated shape [user(edited), brain], matching
|
||||
# the localStorage shape key-for-key (the DB is the durable boundary).
|
||||
row = _poll_saved_messages(
|
||||
Q, lambda msgs: len(msgs) == 2 and msgs[0].get("text") == Q_TEST1_EDITED
|
||||
@@ -507,7 +507,7 @@ def test_edit_drops_a_stale_tail_and_survives_refresh(
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5 — the driver case (TODO.md L3): a failed turn's question is
|
||||
# editable; trimming + Save re-asks successfully (the phase-114
|
||||
# editable; trimming + Send re-asks successfully (the phase-114
|
||||
# "Question too long" frame takes this identical settle path — its
|
||||
# exact detail string is real-endpoint-only, see the module
|
||||
# docstring)
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
"""Phase 170 E2E (Playwright): the edit bubble — the editor lives
|
||||
INSIDE the user bubble, and "Send" re-asks.
|
||||
|
||||
Source: ``TODO.md`` L3 — "the edit bubble is tiny compared to the
|
||||
message bubble. When you click 'edit' it collapses down to this super
|
||||
small text field. It should stay the same size as the user message
|
||||
bubble. Also the save button should read 'send' rather than 'save'
|
||||
because you're not saving it you're re-sending the message." Run in
|
||||
isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_edit_bubble_sizing.py -v --no-cov
|
||||
|
||||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the failure
|
||||
shape is the deterministic injection in ``tests/e2e/mock_llm.py``
|
||||
(the phase-67 ``always fail`` trigger). The phase-114 "Question too
|
||||
long — trim it and re-ask." frame takes the IDENTICAL settle path
|
||||
(this suite's driver case rides it through the mock's
|
||||
``ALWAYS_FAIL_TRIGGER`` 500s → retry-budget exhaustion → the terminal
|
||||
``error`` frame → ``finalizeFailedTurn``): the EXACT detail string is
|
||||
real-endpoint-only (it is the server's embed-input-too-large
|
||||
short-circuit — ``app/api/chat.py`` — which the mock's embeddings
|
||||
endpoint never raises), so the mock's 500 exhaustion (the phase-120
|
||||
zero-frame shape) is the driver case's stand-in.
|
||||
|
||||
The contract under test (phase 170, owner-locked 2026-10-10 —
|
||||
A1/A2):
|
||||
|
||||
* THE EDITOR LIVES INSIDE THE BUBBLE (A1): clicking Edit no longer
|
||||
swaps the bubble for a shrunken sibling form — the ``.edit-form``
|
||||
is a CHILD of the ``.bubble`` (the rendered markdown is cleared,
|
||||
the form takes the box), the bubble stays VISIBLE (the
|
||||
``display:none`` swap is deleted), and it keeps its ``--brand``
|
||||
fill. The box keeps the bubble's dimensions: the open editor's
|
||||
width is ≥ 95 % of the pre-edit width (the "no longer tiny" pin —
|
||||
the old shrunken sibling form failed this by construction) and its
|
||||
height never collapses. Implementation note (task 03): the bubble
|
||||
is shrink-to-fit (``align-items: flex-end``), so the form's
|
||||
``min-width`` is seeded with the bubble's PRE-EDIT width (read
|
||||
through an alias — the phase-166 audit pins the ``bubble`` handle
|
||||
to content-only touches) — the box may grow to the form's natural
|
||||
width, never collapse below the question's box; the inline style
|
||||
rides the form's detach.
|
||||
* THE LABEL IS "SEND" (A2): the submit button reads "Send" (never
|
||||
"Save" — the action re-SENTS the question, it never persists a
|
||||
note), with the "Cancel" sibling.
|
||||
* Everything else of the phase-159 flow stays behavior-identical
|
||||
through the new housing: the prefill (the exact question), the
|
||||
trim + Send re-ask (replace + truncate + ``runTurn(text, {
|
||||
reask: true })`` — the question is never duplicated, the
|
||||
composer's unsent draft survives), the Cancel / ``Escape``
|
||||
restore (the rendered markdown comes back byte-identical), and the
|
||||
failed-turn driver case (trim + Send re-asks successfully, the
|
||||
failed record is replaced).
|
||||
|
||||
KB seed (the phase-120 ``test_failed_turn_retry.py`` direct-seed
|
||||
pattern): ONE fixture document — the "How is my Kubernetes cluster
|
||||
set up?" questions FTS-hit it → HIGH → the grounded path (the
|
||||
re-asked turns stream the grounded ``MOCK_ANSWER_MARKER`` answer).
|
||||
|
||||
Test → contract mapping:
|
||||
|
||||
1. ``test_editor_renders_inside_the_bubble`` — send, grounded answer:
|
||||
the ``.edit-form`` is a CHILD of the bubble (not a
|
||||
``.msg-body`` sibling), the bubble's computed ``display`` is not
|
||||
``none``, its ``background-color`` is UNCHANGED (the ``--brand``
|
||||
fill), ``.is-editing`` is on the bubble, and the textarea
|
||||
prefills the EXACT question.
|
||||
2. ``test_editor_box_keeps_the_bubble_dimensions`` — the open
|
||||
editor's bubble keeps the pre-edit width (≥ 95 %) and never
|
||||
collapses in height (the "no longer tiny" pin); at 375 px the
|
||||
page has no horizontal overflow (the §7.1 reading column).
|
||||
3. ``test_submit_label_reads_send`` — ``.edit-save`` reads "Send"
|
||||
(and not "Save"); ``.edit-cancel`` reads "Cancel".
|
||||
4. ``test_trim_and_send_reasks_in_place`` — the phase-159 flow
|
||||
through the new label: append " in one line" + click the "Send"
|
||||
button: exactly ONE user bubble (the edited text), the old answer
|
||||
is gone, the fresh answer streams, ``bor.chat.v1`` holds
|
||||
``[user(edited), brain]`` with NO duplicate question, the
|
||||
auto-saved ``saved_chats`` row matches the localStorage shape,
|
||||
and the UNSSENT composer draft survives.
|
||||
5. ``test_cancel_restores_the_bubble`` — open the editor, Cancel:
|
||||
the rendered markdown is back (the form is gone, ``.is-editing``
|
||||
is off), the record is byte-identical, focus returns to the
|
||||
toggle; a second pass with ``Escape`` (a fresh turn) pins the
|
||||
same restore through the keydown path.
|
||||
6. ``test_failed_turn_still_editable`` — the driver case (the phase
|
||||
-114 "Question too long" frame's mock stand-in, see the module
|
||||
docstring): the editor opens INSIDE the question's bubble, and
|
||||
trimming (the trigger stripped — under the 4000 cap) + "Send"
|
||||
streams the grounded answer (the failed record is replaced).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models import Chunk, Document
|
||||
from e2e.auth_helpers import login
|
||||
from tests.e2e.mock_llm import (
|
||||
ALWAYS_FAIL_TRIGGER,
|
||||
embed_text,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Seed + questions (see the module docstring for the gate notes)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
SEED_SOURCE = "docs"
|
||||
SEED_PATH = "homelab/kubernetes.md"
|
||||
KUB_CONTENT = (REPO / "tests" / "fixtures" / "docs" / SEED_PATH).read_text()
|
||||
|
||||
#: The grounded question (the phase-120 suite's — FTS-hits the seed).
|
||||
Q = "How is my Kubernetes cluster set up?"
|
||||
#: Test 5's second turn (a fresh turn for the Escape pass).
|
||||
Q2 = "Which workloads does my Kubernetes cluster run?"
|
||||
#: Test 4's edit — append the task's " in one line" to the question.
|
||||
Q_TEST1_EDITED = "How is my Kubernetes cluster set up? in one line"
|
||||
#: Test 6's failure question (the phase-67 trigger — every streaming
|
||||
#: request 500s → the retry-budget exhaustion, the phase-120 shape).
|
||||
FAIL_Q = f"{Q} {ALWAYS_FAIL_TRIGGER}"
|
||||
#: Test 4's unsent composer draft.
|
||||
DRAFT = "draft question about backups — do not send yet"
|
||||
|
||||
#: The app's terminal LLM-failure copy (app/api/chat.py's LLMError
|
||||
#: frame) — the error detail the note/banner/record all carry.
|
||||
ERROR_COPY = "The chat model dropped the connection — try again?"
|
||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
|
||||
STORAGE_KEY = "bor.chat.v1"
|
||||
|
||||
|
||||
def _js_const(name: str) -> str:
|
||||
"""A frontend string constant, read from app.js — the E2E asserts
|
||||
against the SAME text the page renders (no JS/Python drift)."""
|
||||
js = (REPO / "frontend" / "assets" / "app.js").read_text(encoding="utf-8")
|
||||
m = re.search(rf'const {name}\s*=\s*\n?\s*"([^"]*)"', js)
|
||||
assert m, f"const {name} not found in app.js"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
FAILED_TURN_TEXT = _js_const("FAILED_TURN_TEXT")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# DB seeding (TRUNCATE-then-seed, the phase-120 pattern)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed(db: Session) -> None:
|
||||
"""The single fixture document (see the module docstring)."""
|
||||
md = Document(
|
||||
source=SEED_SOURCE,
|
||||
path=SEED_PATH,
|
||||
full_path=f"/tmp/{SEED_PATH}",
|
||||
title="Kubernetes",
|
||||
content=KUB_CONTENT,
|
||||
content_hash=hashlib.sha256(KUB_CONTENT.encode()).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(md)
|
||||
db.flush()
|
||||
# One chunk carrying the mock's own embedding → genuine token overlap
|
||||
# for the grounded questions (the FTS path carries them to HIGH).
|
||||
db.add(
|
||||
Chunk(
|
||||
document_id=md.id,
|
||||
position=0,
|
||||
content=KUB_CONTENT,
|
||||
embedding=embed_text(KUB_CONTENT),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _reset_db() -> None:
|
||||
"""Truncate the KB (plus the prompt-shaping tables), then re-seed.
|
||||
|
||||
``steering_notes`` / ``kb_overview`` are truncated too, so the
|
||||
prompts are byte-stable regardless of leftovers from other suites.
|
||||
"""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
|
||||
)
|
||||
db.commit()
|
||||
_seed(db)
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_chats() -> Iterator[None]:
|
||||
"""The send auto-saves a ``saved_chats`` row per turn (phase 55) —
|
||||
truncate around every test so the suite starts from (and leaves)
|
||||
an empty deployment (the phase-80/103 isolation pattern)."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
yield
|
||||
# The auto-save is fire-and-forget (the browser never awaits the
|
||||
# PUT): a short margin lets the last turn's PUT land server-side
|
||||
# before the TRUNCATE, so the teardown never 500s an in-flight
|
||||
# upsert (the "Could not refresh instance" race). This runs AFTER
|
||||
# the page closes (LIFO finalization), so the PUT is either done
|
||||
# or aborted — never in flight.
|
||||
time.sleep(0.75)
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# localStorage reads + the auto-saved row poll + the turn flow
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stored_raw(page: Page) -> str:
|
||||
"""The raw ``bor.chat.v1`` payload (the byte-identity check's
|
||||
unit — a string, not a parsed dict)."""
|
||||
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
|
||||
assert raw is not None, "the conversation key must exist in localStorage"
|
||||
return raw
|
||||
|
||||
|
||||
def _stored(page: Page) -> dict:
|
||||
"""The persisted ``bor.chat.v1`` conversation (localStorage — the
|
||||
phase-14 store the restore reads on refresh)."""
|
||||
return json.loads(_stored_raw(page))
|
||||
|
||||
|
||||
def _poll_saved_messages(
|
||||
title: str, want: Callable[[list[dict]], bool]
|
||||
) -> list[dict]:
|
||||
"""Wait for the auto-saved row's ``messages`` to match *want* (the
|
||||
phase-55 upserts are fire-and-forget — there is no status line to
|
||||
wait on; the ``#send-label`` settle only proves the LAST save
|
||||
point fired, not that its PUT landed). The row's title keeps the
|
||||
FIRST question on re-Send (title-keep), so the edit's row is the
|
||||
same row, polled by the original title."""
|
||||
deadline = time.monotonic() + 15
|
||||
last: list[dict] | None = None
|
||||
while time.monotonic() < deadline:
|
||||
with SessionLocal() as db:
|
||||
row = db.execute(
|
||||
text("SELECT messages FROM saved_chats WHERE title = :t"),
|
||||
{"t": title},
|
||||
).scalar_one_or_none()
|
||||
if row is not None:
|
||||
last = row
|
||||
if want(row):
|
||||
return row
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(
|
||||
f"the saved_chats row for {title!r} never settled (last: {last!r})"
|
||||
)
|
||||
|
||||
|
||||
def _shape_matches(row: list[dict], stored: list[dict]) -> bool:
|
||||
"""The row's shape matches the localStorage shape key-for-key.
|
||||
|
||||
The DB stores the schema-NORMALIZED shape (``ChatMessage``
|
||||
re-validation — every optional key present as an explicit
|
||||
``None``, the integration suite's ``_expect`` convention), while
|
||||
localStorage keeps the RAW client records (a key subset). The
|
||||
match: every localStorage key equals the row's value, and the row
|
||||
carries no NON-NULL key the record lacks (no stale metadata,
|
||||
no missing content)."""
|
||||
if len(row) != len(stored):
|
||||
return False
|
||||
for r, s in zip(row, stored, strict=True):
|
||||
for k, v in s.items():
|
||||
if r.get(k) != v:
|
||||
return False
|
||||
for k, v in r.items():
|
||||
if k not in s and v is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _submit(page: Page, question: str) -> None:
|
||||
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 _assert_settled(page: Page) -> None:
|
||||
"""The turn has FULLY settled: the ``done`` frame is processed
|
||||
(save point 2 — the answer record persisted through
|
||||
``rememberBrainTurn`` — synchronously in the frame handler) BEFORE
|
||||
the finally's idle state flips the label to Send. The label is
|
||||
the settle signal, so a localStorage / row read after it is
|
||||
race-free for the record's shape (the marker can be visible a few
|
||||
frames before ``done`` — the last deltas carry it)."""
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||
|
||||
|
||||
def _assert_grounded_settled(page: Page) -> str:
|
||||
"""The grounded answer streamed + the turn settled: returns the
|
||||
brain bubble's text."""
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||
_assert_settled(page)
|
||||
return bubble.inner_text()
|
||||
|
||||
|
||||
def _open_editor_inside(page: Page, expected_prefill: str) -> None:
|
||||
"""Click the Edit toggle (it must exist on the last user bubble)
|
||||
and pin the PHASE-170 housing (A1): the ``.edit-form`` is a
|
||||
CHILD of the bubble (not a ``.msg-body`` sibling), the bubble
|
||||
stays VISIBLE, ``.is-editing`` is on the bubble, and the
|
||||
textarea prefills the record's text (exact)."""
|
||||
expect(page.locator(".msg.user .edit-btn")).to_have_count(1)
|
||||
page.click(".msg.user .edit-btn")
|
||||
bubble = page.locator(".msg.user .bubble").last
|
||||
assert bubble.evaluate("el => el.querySelector('.edit-form') !== null"), (
|
||||
"the .edit-form must be a CHILD of the bubble (A1 — the "
|
||||
"editor lives inside the bubble, not a .msg-body sibling)"
|
||||
)
|
||||
assert bubble.evaluate(
|
||||
"el => getComputedStyle(el).display !== 'none'"
|
||||
), (
|
||||
"the bubble stays visible while the editor is open (the "
|
||||
"display:none swap is deleted — A1)"
|
||||
)
|
||||
assert bubble.evaluate("el => el.classList.contains('is-editing')"), (
|
||||
".is-editing is the state class on the bubble (the bubble "
|
||||
"stays visible)"
|
||||
)
|
||||
ta = page.locator(".edit-form textarea")
|
||||
expect(ta).to_be_visible(timeout=10_000)
|
||||
expect(ta).to_have_value(expected_prefill)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1 — A1: the editor renders INSIDE the bubble — visible, the --brand
|
||||
# fill unchanged, the exact prefill, the state class
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_editor_renders_inside_the_bubble(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
_submit(page, Q)
|
||||
_assert_grounded_settled(page)
|
||||
|
||||
bubble = page.locator(".msg.user .bubble")
|
||||
pre_bg = bubble.evaluate("el => getComputedStyle(el).backgroundColor")
|
||||
|
||||
_open_editor_inside(page, Q)
|
||||
|
||||
# The form is the bubble's CONTENT (the .msg-body sibling form is
|
||||
# gone — the shrunken-sibling fix).
|
||||
assert bubble.evaluate("el => el.querySelector('.edit-form') !== null"), (
|
||||
"the .edit-form must be a child of the bubble (A1)"
|
||||
)
|
||||
assert bubble.locator(".edit-form").count() == 1
|
||||
assert page.locator(".msg-body > .edit-form").count() == 0, (
|
||||
"the .msg-body sibling form is gone (the form is the "
|
||||
"bubble's content)"
|
||||
)
|
||||
# The bubble is VISIBLE (the display:none swap is deleted).
|
||||
assert bubble.evaluate("el => getComputedStyle(el).display !== 'none'"), (
|
||||
"the bubble must be visible while the editor is open (A1)"
|
||||
)
|
||||
# The --brand fill is UNCHANGED (the form is transparent — the
|
||||
# bubble provides the background).
|
||||
post_bg = bubble.evaluate("el => getComputedStyle(el).backgroundColor")
|
||||
assert post_bg == pre_bg, (
|
||||
f"the bubble's background is unchanged (got {post_bg!r}, "
|
||||
f"pre-edit {pre_bg!r})"
|
||||
)
|
||||
# The state class is on the bubble (the box IS the box — no
|
||||
# swap, the question appears once as the form's prefill).
|
||||
assert bubble.evaluate("el => el.classList.contains('is-editing')"), (
|
||||
".is-editing must be on the bubble while the editor is open"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2 — A1: the box keeps the bubble's dimensions — the "no longer tiny"
|
||||
# pin (width ≥ 95 %, height never collapses) + 375 px no overflow
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_editor_box_keeps_the_bubble_dimensions(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
_submit(page, Q)
|
||||
_assert_grounded_settled(page)
|
||||
|
||||
bubble = page.locator(".msg.user .bubble")
|
||||
pre = bubble.bounding_box()
|
||||
assert pre is not None
|
||||
|
||||
page.click(".msg.user .edit-btn")
|
||||
expect(page.locator(".edit-form")).to_be_visible(timeout=10_000)
|
||||
post = bubble.bounding_box()
|
||||
assert post is not None
|
||||
|
||||
# The "no longer tiny" pin: the open editor keeps the bubble's
|
||||
# width (≥ 95 % of the pre-edit width) — the old shrunken sibling
|
||||
# form (its own small card in .msg-body) failed this by
|
||||
# construction. The box may GROW (the form's natural width),
|
||||
# never collapse below the question's box.
|
||||
assert post["width"] >= 0.95 * pre["width"], (
|
||||
f"the editor must keep the bubble's width ({post['width']:.0f}px "
|
||||
f"is < 95% of the pre-edit {pre['width']:.0f}px)"
|
||||
)
|
||||
# The box grows, never collapses (the rows-3 textarea + the
|
||||
# action row sit inside the inherited box).
|
||||
assert post["height"] >= pre["height"], (
|
||||
f"the editor's box must never collapse in height "
|
||||
f"({post['height']:.0f}px < the pre-edit {pre['height']:.0f}px)"
|
||||
)
|
||||
|
||||
# At 375 px the bubble has no horizontal overflow (the §7.1
|
||||
# reading column — the 72rem container at every viewport; the
|
||||
# page never scrolls horizontally).
|
||||
page.set_viewport_size({"width": 375, "height": 812})
|
||||
page.wait_for_timeout(150) # the reflow settles before the measure
|
||||
overflow = page.evaluate(
|
||||
"() => document.documentElement.scrollWidth <= window.innerWidth + 1"
|
||||
)
|
||||
assert overflow, "horizontal page overflow at 375px with the editor open"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3 — A2: the submit label reads "Send" (never "Save")
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_submit_label_reads_send(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
_submit(page, Q)
|
||||
_assert_grounded_settled(page)
|
||||
|
||||
_open_editor_inside(page, Q)
|
||||
|
||||
# Phase 170 (A2): the action re-SENDS the question — the label
|
||||
# is "Send" (matching the composer's #send-label "Send" and the
|
||||
# "Cancel" sibling), never "Save" (nothing is persisted
|
||||
# standalone — A10 stateless holds).
|
||||
save = page.locator(".edit-form .edit-save")
|
||||
expect(save).to_have_text("Send")
|
||||
assert "Save" not in save.inner_text(), (
|
||||
"the submit label must read 'Send' (not 'Save')"
|
||||
)
|
||||
expect(page.locator(".edit-form .edit-cancel")).to_have_text("Cancel")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4 — the phase-159 flow through the new label: trim + "Send" replaces
|
||||
# in place, truncates the tail, re-asks; the composer draft
|
||||
# survives
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_trim_and_send_reasks_in_place(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
_submit(page, Q)
|
||||
old_answer = _assert_grounded_settled(page)
|
||||
# The old answer is the ONLY brain bubble (the pre-edit shape).
|
||||
expect(page.locator(".msg.brain")).to_have_count(1)
|
||||
|
||||
# The unsent composer draft (typed AFTER the settle — the edit
|
||||
# must never touch the composer).
|
||||
page.fill("#message-input", DRAFT)
|
||||
|
||||
_open_editor_inside(page, Q)
|
||||
page.fill(".edit-form textarea", Q_TEST1_EDITED)
|
||||
# The "Send" button (A2 — the re-ask, not a save).
|
||||
page.click(".edit-form .edit-save")
|
||||
|
||||
# The old answer bubble is GONE (the truncation removed it); the
|
||||
# question appears ONCE, edited; the fresh answer streams from
|
||||
# the edited text.
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||
expect(page.locator(".msg.user .bubble")).to_have_text(Q_TEST1_EDITED)
|
||||
new_answer = _assert_grounded_settled(page)
|
||||
expect(page.locator(".msg.brain")).to_have_count(1)
|
||||
assert new_answer != old_answer, (
|
||||
"the old answer is gone — the new one streams"
|
||||
)
|
||||
|
||||
# The record (localStorage): [user(edited), brain] — the edited
|
||||
# text replaces the record in place, the tail is dropped, and
|
||||
# the question is never DUPLICATED.
|
||||
stored = _stored(page)
|
||||
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
|
||||
assert stored["messages"][0]["text"] == Q_TEST1_EDITED
|
||||
assert Q not in [m["text"] for m in stored["messages"]], (
|
||||
"no duplicate question — the old text is gone from the record"
|
||||
)
|
||||
assert MOCK_ANSWER_MARKER in stored["messages"][1]["text"]
|
||||
|
||||
# The auto-saved row (polled — the title keeps the FIRST question
|
||||
# on re-Send): the truncated shape, matching the localStorage
|
||||
# shape key-for-key (the DB is the durable boundary).
|
||||
row = _poll_saved_messages(
|
||||
Q, lambda msgs: len(msgs) == 2 and msgs[0].get("text") == Q_TEST1_EDITED
|
||||
)
|
||||
assert _shape_matches(row, stored["messages"]), (
|
||||
"the saved_chats row matches the localStorage shape — the "
|
||||
"truncated conversation persisted (no stale tail, no duplicate)"
|
||||
)
|
||||
|
||||
# The unsent draft SURVIVES the edit (the save handler never
|
||||
# writes input.value / autoGrow / updateCharCount).
|
||||
assert page.locator("#message-input").input_value() == DRAFT
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5 — the cancel restore: the rendered markdown comes back byte-
|
||||
# identical (the Cancel button pass + the Escape keydown pass on a
|
||||
# fresh turn)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cancel_restores_the_bubble(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
# Pass 1 — the Cancel button (a real edit attempt, then cancel).
|
||||
_submit(page, Q)
|
||||
_assert_grounded_settled(page)
|
||||
question_text = page.locator(".msg.user .bubble").inner_text()
|
||||
before = _stored_raw(page)
|
||||
|
||||
_open_editor_inside(page, Q)
|
||||
page.fill(".edit-form textarea", "something entirely different")
|
||||
page.click(".edit-form .edit-cancel")
|
||||
|
||||
# The form is gone; the rendered markdown is BACK; .is-editing is
|
||||
# off; the record is byte-identical (cancel never touches it).
|
||||
expect(page.locator(".edit-form")).to_have_count(0)
|
||||
expect(page.locator(".msg.user .bubble")).to_have_text(question_text)
|
||||
assert not page.locator(".msg.user .bubble").evaluate(
|
||||
"el => el.classList.contains('is-editing')"
|
||||
), ".is-editing must be OFF after the cancel restore"
|
||||
assert _stored_raw(page) == before, (
|
||||
"cancel is a no-op — the localStorage payload is byte-identical"
|
||||
)
|
||||
# Focus RETURNED to the Edit toggle (the openTuneForm cancel rule).
|
||||
is_toggle = page.evaluate(
|
||||
"() => !!(document.activeElement && document.activeElement.classList"
|
||||
" && document.activeElement.classList.contains('edit-btn'))"
|
||||
)
|
||||
assert is_toggle, "focus must return to the Edit toggle on Cancel"
|
||||
|
||||
# Pass 2 — the Escape keydown path, a FRESH turn.
|
||||
_submit(page, Q2)
|
||||
_assert_grounded_settled(page)
|
||||
question2 = page.locator(".msg.user .bubble").last.inner_text()
|
||||
before2 = _stored_raw(page)
|
||||
|
||||
_open_editor_inside(page, Q2)
|
||||
page.fill(".edit-form textarea", "something else entirely")
|
||||
page.locator(".edit-form textarea").press("Escape")
|
||||
|
||||
expect(page.locator(".edit-form")).to_have_count(0)
|
||||
expect(page.locator(".msg.user .bubble").last).to_have_text(question2)
|
||||
assert not page.locator(".msg.user .bubble").last.evaluate(
|
||||
"el => el.classList.contains('is-editing')"
|
||||
), ".is-editing must be OFF after the Escape restore"
|
||||
assert _stored_raw(page) == before2, (
|
||||
"Escape is a no-op — the localStorage payload is byte-identical"
|
||||
)
|
||||
# The DROPPED edit never leaked into the record (both restores
|
||||
# left the conversation byte-identical).
|
||||
stored = _stored(page)
|
||||
assert "something" not in json.dumps(stored)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 6 — the driver case (TODO.md L3): a failed turn's question is
|
||||
# editable INSIDE its bubble; trimming + "Send" re-asks
|
||||
# successfully (the phase-114 "Question too long" frame takes this
|
||||
# identical settle path — the mock's 500 exhaustion is the stand-
|
||||
# in, see the module docstring)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_failed_turn_still_editable(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
_submit(page, FAIL_Q)
|
||||
|
||||
# The ``always fail`` trigger: every streaming request 500s → the
|
||||
# retry-budget exhaustion → the terminal error frame — the
|
||||
# phase-120 zero-frame shape (banner + the failed bubble).
|
||||
banner = page.locator("#kb-banner")
|
||||
expect(banner).to_have_attribute("role", "alert", timeout=60_000)
|
||||
expect(banner).to_contain_text(ERROR_COPY)
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||
brain = page.locator(".msg.brain")
|
||||
expect(brain).to_have_count(1)
|
||||
expect(brain.locator(".bubble")).to_have_text(FAILED_TURN_TEXT)
|
||||
expect(brain.locator(".retry-btn")).to_have_count(1)
|
||||
|
||||
# THE remedy through the new housing: Edit exists on the
|
||||
# question, and the editor opens INSIDE the bubble (A1).
|
||||
_open_editor_inside(page, FAIL_Q)
|
||||
bubble = page.locator(".msg.user .bubble")
|
||||
assert bubble.evaluate("el => el.querySelector('.edit-form') !== null"), (
|
||||
"the editor must open INSIDE the bubble (the failed turn's "
|
||||
"question is editable in place)"
|
||||
)
|
||||
assert bubble.evaluate("el => getComputedStyle(el).display !== 'none'"), (
|
||||
"the bubble stays visible while the editor is open (A1)"
|
||||
)
|
||||
|
||||
# Trimming (the trigger stripped — under the 4000 cap) + "Send"
|
||||
# re-asks successfully: the grounded answer streams in place of
|
||||
# the failed bubble (the failed record is REPLACED — never kept,
|
||||
# never rewritten).
|
||||
page.fill(".edit-form textarea", Q)
|
||||
page.click(".edit-form .edit-save")
|
||||
|
||||
_assert_grounded_settled(page)
|
||||
expect(page.locator(".msg.user .bubble")).to_have_text(Q)
|
||||
expect(page.locator(".msg.brain")).to_have_count(1)
|
||||
expect(page.locator(".failed-note")).to_have_count(0)
|
||||
stored = _stored(page)
|
||||
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
|
||||
assert stored["messages"][0]["text"] == Q
|
||||
assert stored["messages"][-1].get("failed") in (None, False), (
|
||||
"the failed record is replaced — it never survives the edit"
|
||||
)
|
||||
assert FAILED_TURN_TEXT not in stored["messages"][-1]["text"]
|
||||
Reference in New Issue
Block a user