"""Phase 159 E2E (Playwright): edit the last question and re-ask it. Source: ``TODO.md`` L3 — "Need to be able to edit and resend a message. Got 'Question too long — trim it and re-ask' but I can't trim it because I can't edit it." Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_edit_and_resend.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 159, owner-locked 2026-10-05): * EXACTLY ONE editable bubble — the most recent user message (the ``markLastRetryable`` single-target discipline, one ``who`` over); 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, Send/Cancel, a ``role=alert`` error line); Cancel/``Escape`` restore the bubble and return focus to the toggle. * 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 ``runTurn(text, { reask: true })`` — the question is never duplicated, the composer's unsent draft survives. * In-flight discipline: no Edit button while a turn runs; the button lands at the settle. KB seed (the phase-120 ``test_failed_turn_retry.py`` direct-seed pattern, the task's ``docs/homelab/kubernetes.md``): 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_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" + 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 auto-saved ``saved_chats`` row matches the localStorage shape key- for-key (polled — the fire-and-forget PUTs land in order, the truncated shape is the final state; the DB stores the schema-normalized shape, the integration ``_expect`` convention). 2. ``test_only_the_last_question_is_editable`` — two turns: exactly ONE ``.edit-btn`` exists, on the SECOND user bubble; the first has none (the single-target discipline, owner-locked). 3. ``test_edit_cancel_is_a_no_op`` — open the editor, ``Escape``: the bubble text, the record, and the answer are unchanged (the ``localStorage`` payload is byte-identical before/after), the ``.edit-form`` is gone, and focus RETURNS to the Edit toggle (``document.activeElement`` is the ``.edit-btn``). 4. ``test_edit_drops_a_stale_tail_and_survives_refresh`` — after an edit-save, ``page.reload()``: the edited question + the new answer restore, the DROPPED answer never reappears (one brain bubble, the fresh one — the old text is absent), and Edit is back on the question (the restore's mark pair). 5. ``test_failed_turn_question_is_editable_and_trim_reasks`` — the driver case: the ``always fail`` trigger settles the turn with the terminal ``error`` frame + the failed bubble (the phase-120 zero-frame shape — the phase-114 "question too long" frame takes 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) + 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 (the in-flight guard — the button never appears or moves mid-turn); at the ``done`` settle the button lands. 7. ``test_composer_draft_survives_an_edit`` — negative: a typed, UNSSENT composer draft survives the edit-save (the save handler never touches ``input.value`` / ``autoGrow`` / ``updateCharCount`` — the composer keeps its own draft). """ 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, LONG_ANSWER_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 2's second turn (the seed's workloads section — FTS-hits too). Q2 = "Which workloads does my Kubernetes cluster run?" #: Test 4's edit — the original tokens plus one (grounding preserved — #: a superset of the seed's hit tokens), DISTINCT from the original so #: the mock's question echo makes the old vs the new answer #: distinguishable byte-for-byte. Q_EDITED = "How is my Kubernetes cluster set up, briefly?" #: Test 1'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 5'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 6's long answer (the mock's ~8 s pacing — the in-flight window). LONG_Q = f"{Q} {LONG_ANSWER_TRIGGER}" #: Test 7'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 (the phase-120 ``_assert_redo_succeeded`` shape, minus the redo's failed-marker checks).""" 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(page: Page, expected_prefill: str) -> None: """Click the Edit toggle (it must exist on the last user bubble) and pin the textarea's prefill (the record's text, exact).""" expect(page.locator(".msg.user .edit-btn")).to_have_count(1) page.click(".msg.user .edit-btn") ta = page.locator(".edit-form textarea") expect(ta).to_be_visible(timeout=10_000) expect(ta).to_have_value(expected_prefill) def _save_editor(page: Page, new_text: str) -> None: page.fill(".edit-form textarea", new_text) page.click(".edit-form .edit-save") # -------------------------------------------------------------------------- # 1 — the flow: send → Edit (prefilled) → trim + Send → the old answer # is gone, the new one streams, the record + the row are truncated # -------------------------------------------------------------------------- def test_edit_last_question_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) _open_editor(page, Q) _save_editor(page, Q_TEST1_EDITED) # 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 (no record carries the old text; # the new answer merely echoes the edited question). 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 [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 ) assert _shape_matches(row, stored["messages"]), ( "the saved_chats row matches the localStorage shape — the " "truncated conversation persisted (no stale tail, no duplicate)" ) # -------------------------------------------------------------------------- # 2 — the single-target discipline: exactly ONE editable bubble, the # NEWEST question (owner-locked 2026-10-05) # -------------------------------------------------------------------------- def test_only_the_last_question_is_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, Q) _assert_grounded_settled(page) _submit(page, Q2) _assert_grounded_settled(page) # Exactly ONE .edit-btn in the whole DOM, on the SECOND user # bubble; the first (the older question) carries none — the # remove-then-append single target, one `who` over. expect(page.locator(".edit-btn")).to_have_count(1) expect(page.locator(".msg.user").nth(1).locator(".edit-btn")).to_have_count(1) expect(page.locator(".msg.user").nth(0).locator(".edit-btn")).to_have_count(0) # -------------------------------------------------------------------------- # 3 — negative: Cancel (Escape) is a no-op — the bubble, the record, # and the answer are unchanged; focus returns to the toggle # -------------------------------------------------------------------------- def test_edit_cancel_is_a_no_op( 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) answer = _assert_grounded_settled(page) question_text = page.locator(".msg.user .bubble").inner_text() before = _stored_raw(page) expect(page.locator(".msg.user .edit-btn")).to_have_count(1) page.click(".msg.user .edit-btn") ta = page.locator(".edit-form textarea") expect(ta).to_be_visible(timeout=10_000) # A real edit attempt (so the no-op is meaningful): the textarea is # changed, then Escape cancels. page.fill(".edit-form textarea", "something entirely different") ta.press("Escape") # The form is gone; the bubble + the answer are byte-identical; the # localStorage payload is byte-identical (nothing persisted — # cancel never touches the record). expect(page.locator(".edit-form")).to_have_count(0) expect(page.locator(".msg.user .bubble")).to_have_text(question_text) assert page.locator(".msg.brain .bubble").last.inner_text() == answer 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 Escape" # -------------------------------------------------------------------------- # 4 — the truncated shape survives a refresh: the edited question + the # new answer restore; the dropped answer never reappears; Edit is # back on the question # -------------------------------------------------------------------------- def test_edit_drops_a_stale_tail_and_survives_refresh( 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) _open_editor(page, Q) _save_editor(page, Q_EDITED) new_answer = _assert_grounded_settled(page) assert new_answer != old_answer # The refresh: the restore reads the truncated localStorage shape — # the edited question + the fresh answer. page.reload() expect(page.locator("#messages > .msg")).to_have_count(2) expect(page.locator(".msg.user .bubble")).to_have_text(Q_EDITED) restored = page.locator(".msg.brain .bubble").last expect(restored).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000) assert page.locator(".msg.brain .bubble").last.inner_text() != old_answer, ( "the DROPPED answer never reappears — the restore shows the " "fresh one (no stale tail resurrected)" ) # Edit is back on the restored question (the restore's mark pair — # the mirror of the live settle). expect(page.locator(".msg.user .edit-btn")).to_have_count(1) # The restored record is the truncated shape (the restore is # lossless: the edited text, the fresh answer, no duplicate). stored = _stored(page) assert [m["who"] for m in stored["messages"]] == ["user", "brain"] assert stored["messages"][0]["text"] == Q_EDITED assert Q not in [m["text"] for m in stored["messages"]] # -------------------------------------------------------------------------- # 5 — the driver case (TODO.md L3): a failed turn's question is # 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) # -------------------------------------------------------------------------- def test_failed_turn_question_is_editable_and_trim_reasks( 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("#banner-retry")).to_be_visible(timeout=10_000) 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) note = brain.locator(".failed-note") expect(note).to_have_count(1) expect(note).to_contain_text(ERROR_COPY) expect(brain.locator(".retry-btn")).to_have_count(1) # THE remedy: Edit exists on the question BESIDE the failed # bubble's Retry (the guard bails on thinking/streaming only — the # error state passes; the failed record is the stale tail the # edit-save truncates). expect(page.locator(".msg.user .edit-btn")).to_have_count(1) _open_editor(page, FAIL_Q) _save_editor(page, Q) # the trigger stripped out # The trimmed question re-asks successfully: the grounded answer # streams in place of the failed bubble (the failed record is # dropped — never kept, never rewritten). _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 part of the dropped tail — it never " "survives the edit" ) assert FAILED_TURN_TEXT not in stored["messages"][-1]["text"] # -------------------------------------------------------------------------- # 6 — in-flight discipline: no Edit button while a turn runs; the # button lands at the settle # -------------------------------------------------------------------------- def test_edit_is_inert_while_the_turn_runs( 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="/") # The ~8 s long answer — a comfortable in-flight window (the # mock's 12-char / 0.02 s pacing). _submit(page, LONG_Q) # The turn is in flight (the button IS the Stop control — # phase 48). While it runs the Edit button never appears (the # in-flight guard — the button never appears or moves mid-turn). expect(page.locator("#send-label")).to_have_text("Stop", timeout=30_000) expect(page.locator(".edit-btn")).to_have_count(0) # At the ``done`` settle the button lands (the idle-settle mark # pass — the at-rest landing). expect(page.locator("#send-label")).to_have_text("Send", timeout=60_000) expect(page.locator(".msg.user .edit-btn")).to_have_count(1) # -------------------------------------------------------------------------- # 7 — negative: the composer's unsent draft survives an edit (the save # handler never touches the composer — task 02's work item) # -------------------------------------------------------------------------- def test_composer_draft_survives_an_edit( 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) # The unsent draft (typed AFTER the settle — the composer keeps its # own draft, untouched by the edit below). page.fill("#message-input", DRAFT) _open_editor(page, Q) _save_editor(page, Q_EDITED) _assert_grounded_settled(page) # The draft is STILL in the composer (the save handler never writes # input.value / autoGrow / updateCharCount — the edit is the # in-bubble editor's job, the composer's is separate). assert page.locator("#message-input").input_value() == DRAFT