Files
brain-of-reese/tests/e2e/test_edit_bubble_sizing.py
ducoterra c4f098d0c0
Build and Push Containers / build-and-push-app (push) Successful in 1m42s
Build and Push Containers / build-and-push-db (push) Successful in 13s
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`
2026-10-10 03:25:40 -04:00

667 lines
28 KiB
Python

"""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"]