All verification complete. Final report: **Phase 119 final verification pass — all criteria verified, one stale pin fixed.** - Verified implementation of all 6 tasks: D1 component name-hit rule (`name_hit` flag, titles never matched, retired length tie-break), D2 `BOR_NAME_HIT_BONUS` (0.005 default, 0 = byte-identical kill switch, negative fails startup, selection-layer only, `eval_retrieval` `suggested:` line), D3 suggested-folder lines (after `SUGGEST_INTRO`, before first block), D4 cite-discipline `SUGGEST_INTRO` sentence (PERSONA/LOW/`TOOLS_SECTION` byte-pins intact), D5 `done.sources` = read docs only (frontend no-op on empty confirmed), D6 mock `repeat your folder map` echo + new suite + telemetry. - Battery (replica restored per skill, fingerprint docs=1000/chunks=8866 verified, `eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` re-run): **GATE PASS** — gitea README #4 in suggested top-5, forgejo 5/5 (README #1), gateway README in top-5 (#4), qwen3.8-27b quadlets top-5, Mongolia HIGH/fts=5 unchanged. - New E2E in isolation: `4 passed` ×2 (deterministic). All 27 modified E2E suites in isolation: 26 green; **1 stale pin fixed** — `test_source_chip_quality.py` durable-record order pin pre-dated the D1 re-rank (`aliases` stem sub-component name-hits `ssh_aliases.txt`, deterministically lifting `backups.md` over `kubernetes.md`; probe-verified 0.016277 vs 0.016036, 4/4 stable) — re-pinned with the phase-119 rationale; suite green ×2. - Gates: `uv run pytest --cov=app --cov-report=term-missing` → **2547 passed, app coverage 99%** (>90%); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors. - Completion criteria: 1 ✅ (battery, recorded), 2 ✅ (folder lines; block/LOW byte-identical pins green), 3 ✅ (read-only chips, zero-read chips nothing, related row + durable record untouched — unit+E2E agree), 4 ✅ (all green), 5 → commit/phase-move left to the harness per pass rules (nothing committed). - Deviations: battery output + real-model telemetry recorded in `.agents/reports/119_name_signal_read_chips/task06_battery_and_e2e.md` and `TOOL_CALLING_TESTING.md` §11 (task files in `complete/` are immutable to this pass); gateway canonical doc at #4 vs overview's #3 was already documented at task 06 (containment gate met). - Next pending phase: **none** — `todo/` holds only phase 119.
587 lines
25 KiB
Python
587 lines
25 KiB
Python
"""Phase 42 E2E (Playwright, mock-only): the chat NEVER autoscrolls.
|
||
|
||
Story: ``.agents/user_stories/no-reply-autoscroll.md`` — owner direction
|
||
2026-08-27 (TODO.md L5) revising the phase-18 follow-the-bottom choice:
|
||
"Get rid of the chat reply autoscroll, it's breaking things like making
|
||
it impossible for the user to scroll while a reply generates."
|
||
|
||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||
|
||
uv run pytest tests/e2e/test_no_reply_autoscroll.py -v --no-cov
|
||
|
||
This is the INVERSE of the phase-18 contract: while a turn streams
|
||
(thinking, tool, or answer frames), nothing moves the viewport — a user
|
||
reading earlier content stays exactly where they put it for the rest of
|
||
the turn. The only scrolls left in the app are user intent: the submit
|
||
(the user's own message is revealed) and the phase-14 restore landing
|
||
(one-shot, load-time). The phase-18 suite
|
||
(``tests/e2e/test_follow_bottom_scroll.py``) is deleted with this one —
|
||
its behavior was intentionally removed.
|
||
|
||
MOCK-ONLY suite: the scenarios key off the deterministic mock's
|
||
``write a long answer`` trigger (~900 words ≈ 8–11s of streaming — a
|
||
wide, reliable window to scroll away in) and the phase-17
|
||
``think out loud`` trigger (~4.5s reasoning stream). ``E2E_REAL_LLM=1``
|
||
would make the scroll-away windows unpredictable, so it is not supported
|
||
here.
|
||
|
||
Measurement convention: the scroller is the DOCUMENT — there is no inner
|
||
scroll container. ``window.scrollY`` is read via ``page.evaluate``;
|
||
"stable" means every sample is within 1px of every other sample (the
|
||
story's tolerance). The stylesheet sets no ``scroll-behavior``, so
|
||
``window.scrollTo(0, y)`` is instant — the recorded position is the exact
|
||
position the stream must not move.
|
||
|
||
Test → story mapping (Playwright Mapping Rule):
|
||
1. ``test_no_autoscroll_during_long_answer``
|
||
2. ``test_no_autoscroll_during_thinking``
|
||
3. ``test_submit_reveals_user_message``
|
||
4. ``test_restore_landing_one_shot``
|
||
5. ``test_answer_content_intact``
|
||
6. ``test_submit_does_not_hop_up`` — regression (2026-08-29, owner
|
||
report): submitting from the document bottom must NOT pull the page
|
||
up. The old ``scrollIntoView({ block: "end" })`` reveal aligned the
|
||
message's bottom to the viewport bottom — which sits above the
|
||
in-flow composer — so every Enter hopped the page up by the
|
||
composer+footer height and pushed the composer below the fold. The
|
||
reveal now lands at the document bottom (composer stays in view).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import time
|
||
from collections.abc import Iterator
|
||
from pathlib import Path
|
||
from threading import Thread
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from playwright.sync_api import Page, expect
|
||
from sqlalchemy import text
|
||
|
||
from app.config import Settings
|
||
from app.db import SessionLocal
|
||
from app.rag.importer import ImportSummary, import_sources
|
||
from app.rag.llm import LLMClient
|
||
from e2e.auth_helpers import login
|
||
|
||
REPO = Path(__file__).resolve().parents[2]
|
||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||
|
||
#: Mock long-answer trigger (~900 words ≈ 8–11s of streaming at the mock's
|
||
#: 0.02s/frame pace) — the wide, deterministic window to scroll away in.
|
||
LONG_QUESTION = "write a long answer about my kubernetes cluster"
|
||
#: Phase-17 thinking trigger: a ~4.5s reasoning stream, then a short
|
||
#: grounded mock answer (both fire independently of the long trigger).
|
||
THINKING_QUESTION = "think out loud about my kubernetes cluster"
|
||
#: Short grounded question (phase-14 marker answer).
|
||
SHORT_QUESTION = "How is my Kubernetes cluster set up?"
|
||
#: The mock long answer's unique final line (mock_llm.LONG_ANSWER_END) —
|
||
#: proves the whole stream landed even while the viewport was up.
|
||
LONG_ANSWER_END = "LONG-ANSWER-END"
|
||
#: Line fragment the mock's deterministic scratchpad carries
|
||
#: (mock_llm.compose_thinking) — same key phase 17's suite uses.
|
||
THINKING_FRAGMENT = "Step 2: Check my notes"
|
||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||
STORAGE_KEY = "bor.chat.v1"
|
||
|
||
#: The story's stability tolerance: the viewport must not move more than
|
||
#: 1px while a turn streams with the user scrolled away.
|
||
STABLE_PX = 1
|
||
|
||
|
||
async def _import_fixtures(mock_port: int) -> ImportSummary:
|
||
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
|
||
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||
return await import_sources([FIXTURES], LLMClient(settings))
|
||
|
||
|
||
def _run_in_thread(coro: Any) -> Any:
|
||
"""Run a coroutine on a worker thread.
|
||
|
||
Playwright's sync API keeps an asyncio loop running on the test thread,
|
||
so ``asyncio.run`` cannot be called directly from a test body.
|
||
"""
|
||
box: dict[str, Any] = {}
|
||
|
||
def runner() -> None:
|
||
try:
|
||
box["value"] = asyncio.run(coro)
|
||
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
|
||
box["error"] = e
|
||
|
||
t = Thread(target=runner)
|
||
t.start()
|
||
t.join()
|
||
if "error" in box:
|
||
raise box["error"]
|
||
return box["value"]
|
||
|
||
|
||
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
|
||
"""Truncate the KB (and query log), then optionally re-import fixtures."""
|
||
with SessionLocal() as db:
|
||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||
db.commit()
|
||
if not seed:
|
||
return None
|
||
return _run_in_thread(_import_fixtures(mock_port))
|
||
|
||
|
||
@pytest.fixture()
|
||
def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]:
|
||
"""A fresh KB seeded from ``tests/fixtures/docs`` (13 docs since phase
|
||
47, A9 formats), truncated again on teardown. ``db_ready`` (conftest)
|
||
skips with clear instructions when Postgres is down."""
|
||
summary = _reset_db(mock_llm, seed=True)
|
||
assert summary is not None and summary.added == 13
|
||
yield
|
||
_reset_db(mock_llm, seed=False)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Measurement + flow helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def scroll_state(page: Page) -> dict[str, float]:
|
||
"""The document scroller's state (there is no inner scroll container)."""
|
||
return page.evaluate(
|
||
"() => ({ y: window.scrollY, "
|
||
"sh: document.documentElement.scrollHeight, "
|
||
"ch: window.innerHeight })"
|
||
)
|
||
|
||
|
||
def brain_bubble_longer_than(n: int, min_bubbles: int = 1) -> str:
|
||
"""JS predicate: there are at least ``min_bubbles`` brain bubbles and
|
||
the LAST one's rendered text is > n chars (i.e. that far into the
|
||
stream). ``min_bubbles=2`` guards a second turn: before its first
|
||
delta, ``.last`` would still be the PREVIOUS turn's bubble."""
|
||
return (
|
||
"() => { const els = document.querySelectorAll('.msg.brain .bubble');"
|
||
f" return els.length >= {min_bubbles} && els[els.length - 1].innerText.length > {n}; }}"
|
||
)
|
||
|
||
|
||
def wait_settled(page: Page) -> None:
|
||
"""The turn is over: the label is back to "Send" (phase 48: the
|
||
button is never disabled — the in-flight state is the enabled Stop
|
||
control, so the label is the settle marker). Phase 48 note: the old
|
||
contract let ``to_be_enabled`` block until the turn settled (the
|
||
in-flight button was disabled); now the label assertion carries the
|
||
wait — with an explicit timeout, since Playwright expect's default
|
||
(5s) is shorter than the mock's long turn (~9.5s) and does not
|
||
inherit the page default."""
|
||
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||
|
||
|
||
def wait_scroll_still(page: Page, timeout: float = 10.0) -> float:
|
||
"""window.scrollY once the viewport has stopped moving (two consecutive
|
||
reads within STABLE_PX). The submit's smooth reveal and the restore
|
||
landing's smooth scroll are the only animations left — both settle
|
||
this way before any stream measurement begins."""
|
||
deadline = time.monotonic() + timeout
|
||
prev: float | None = None
|
||
while True:
|
||
y = scroll_state(page)["y"]
|
||
if prev is not None and abs(y - prev) <= STABLE_PX:
|
||
return y
|
||
prev = y
|
||
if time.monotonic() >= deadline:
|
||
raise AssertionError("the viewport did not settle within timeout")
|
||
time.sleep(0.25)
|
||
|
||
|
||
def submit(page: Page, question: str) -> None:
|
||
"""Submit through the composer (the real-user flow). Playwright's
|
||
fill/click scroll the composer into view first — a user-initiated
|
||
move, never an app scroll."""
|
||
page.fill("#message-input", question)
|
||
page.click("#send-btn")
|
||
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
|
||
|
||
|
||
def submit_from_top(page: Page, question: str) -> None:
|
||
"""Submit with the viewport where the user left it (the very top).
|
||
|
||
``page.fill``/``page.click`` would scroll the composer into view
|
||
first — which IS the viewport move under test — so the send goes
|
||
through the page's own DOM: set the value, fire ``input`` (autoGrow),
|
||
click the submit button. A JS click never scrolls the page."""
|
||
page.evaluate(
|
||
"""(q) => {
|
||
const input = document.querySelector('#message-input');
|
||
input.value = q;
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
document.querySelector('#send-btn').click();
|
||
}""",
|
||
question,
|
||
)
|
||
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
|
||
|
||
|
||
def user_message_in_view(page: Page) -> bool:
|
||
"""The LAST user message's box is fully inside the viewport
|
||
(the submit reveal aligns it to the bottom edge)."""
|
||
box = page.locator(".msg.user").last.bounding_box()
|
||
if box is None:
|
||
return False
|
||
ch = scroll_state(page)["ch"]
|
||
return box["y"] >= -1 and box["y"] + box["height"] <= ch + 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. No autoscroll: scrolled up mid-ANSWER — the viewport holds for the
|
||
# rest of the turn (the answer finishes off-screen below, by design)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_no_autoscroll_during_long_answer(
|
||
page: Page, app_url: str, seeded_kb: None
|
||
) -> None:
|
||
page.set_default_timeout(30_000)
|
||
login(page, app_url, next="/")
|
||
|
||
# Turn 1 (settled) makes the document overflow the 800px viewport.
|
||
submit(page, LONG_QUESTION)
|
||
wait_settled(page)
|
||
state = scroll_state(page)
|
||
assert state["sh"] > state["ch"], "a long answer must make the document scrollable"
|
||
|
||
# Turn 2: the same long answer. The submit reveals the user's message
|
||
# (the one kept app scroll) — let that smooth reveal settle first.
|
||
submit(page, LONG_QUESTION)
|
||
page.wait_for_function(brain_bubble_longer_than(200, min_bubbles=2), timeout=30_000)
|
||
y0 = wait_scroll_still(page)
|
||
|
||
# The user scrolls UP ~2× the answer's current height to read earlier
|
||
# context while the stream is still running.
|
||
box = page.locator(".msg.brain").last.bounding_box()
|
||
assert box is not None
|
||
target = max(0.0, y0 - 2 * box["height"])
|
||
assert target <= y0 - STABLE_PX, "the scroll-up must actually move the viewport"
|
||
page.evaluate("y => window.scrollTo(0, y)", target)
|
||
assert abs(scroll_state(page)["y"] - target) <= STABLE_PX
|
||
|
||
# Sample the viewport across the rest of the stream ...
|
||
# Phase 48: in-flight detection rides the Stop label (the button is
|
||
# the enabled Stop control while a turn runs — it is no longer
|
||
# disabled, so the disabled-state proxy is gone).
|
||
samples: list[float] = []
|
||
mid_stream = 0
|
||
deadline = time.monotonic() + 40
|
||
while time.monotonic() < deadline:
|
||
time.sleep(0.25)
|
||
samples.append(scroll_state(page)["y"])
|
||
if page.locator("#send-label").inner_text() == "Stop":
|
||
mid_stream += 1
|
||
if len(samples) >= 10 and page.locator("#send-label").inner_text() == "Send":
|
||
# ... and a few more AFTER `done` (the turn is over; nothing
|
||
# queued behind the stream may move the page either).
|
||
for _ in range(3):
|
||
time.sleep(0.3)
|
||
samples.append(scroll_state(page)["y"])
|
||
break
|
||
else:
|
||
raise AssertionError("the long turn did not settle within 40s")
|
||
assert mid_stream >= 8, "the samples must land while the stream is running"
|
||
|
||
spread = max(samples) - min(samples)
|
||
assert spread <= STABLE_PX, (
|
||
f"the viewport moved {spread:.1f}px while the user was scrolled up "
|
||
"(no-reply-autoscroll contract)"
|
||
)
|
||
|
||
# The whole answer still landed (off-screen below — by design).
|
||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(LONG_ANSWER_END)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. No autoscroll: scrolled up during THINKING — the whole reasoning
|
||
# stream plus the answer's start happen with the viewport held
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_no_autoscroll_during_thinking(
|
||
page: Page, app_url: str, seeded_kb: None
|
||
) -> None:
|
||
page.set_default_timeout(30_000)
|
||
login(page, app_url, next="/")
|
||
|
||
# One settled long turn so the document overflows (scrollable).
|
||
submit(page, LONG_QUESTION)
|
||
wait_settled(page)
|
||
|
||
# The thinking turn: the submit reveals the user's message (the kept
|
||
# app scroll) ...
|
||
submit(page, THINKING_QUESTION)
|
||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||
details.wait_for(state="attached", timeout=10_000)
|
||
expect(details).to_have_attribute("open", "") # created open (phase 17)
|
||
# ... and, once the reasoning stream is clearly running ...
|
||
page.wait_for_function(
|
||
"() => { const el = document.querySelector('details.thinking .thinking-text');"
|
||
" return !!el && el.innerText.length > 300; }",
|
||
timeout=30_000,
|
||
)
|
||
# ... let the submit's smooth reveal settle, then the user goes up.
|
||
wait_scroll_still(page)
|
||
page.evaluate("() => window.scrollTo(0, 0)")
|
||
|
||
# Sample across the remaining thinking stream: no per-chunk follow.
|
||
samples: list[float] = []
|
||
open_samples = 0
|
||
deadline = time.monotonic() + 25
|
||
while time.monotonic() < deadline:
|
||
time.sleep(0.3)
|
||
state = page.evaluate(
|
||
"""() => {
|
||
const block = document.querySelector('details.thinking');
|
||
const wrap = block ? block.closest('.msg.brain') : null;
|
||
const el = wrap ? wrap.querySelector('.bubble') : null;
|
||
return { y: window.scrollY,
|
||
open: !!(block && block.open),
|
||
bubble: el ? el.innerText.length : 0 };
|
||
}"""
|
||
)
|
||
samples.append(state["y"])
|
||
if state["open"]:
|
||
open_samples += 1
|
||
if state["bubble"] > 0 and len(samples) >= 8:
|
||
break
|
||
else:
|
||
raise AssertionError("the first answer token never arrived")
|
||
assert open_samples >= 6, "the samples must land while the thinking stream is open"
|
||
|
||
spread = max(samples) - min(samples)
|
||
assert spread <= STABLE_PX, (
|
||
f"the viewport moved {spread:.1f}px during the thinking stream "
|
||
"(no per-chunk page follow)"
|
||
)
|
||
|
||
# Settled: still at the top, everything landed (off-screen, which is
|
||
# the point of the story).
|
||
wait_settled(page)
|
||
assert abs(scroll_state(page)["y"]) <= STABLE_PX
|
||
expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
|
||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. Submit: scrolled to the very top, sending a question still reveals
|
||
# the user's own message (the kept, user-initiated scroll)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_submit_reveals_user_message(
|
||
page: Page, app_url: str, seeded_kb: None
|
||
) -> None:
|
||
page.set_default_timeout(30_000)
|
||
login(page, app_url, next="/")
|
||
|
||
# A populated conversation that overflows the viewport.
|
||
submit(page, LONG_QUESTION)
|
||
wait_settled(page)
|
||
state = scroll_state(page)
|
||
assert state["sh"] > state["ch"], "a long answer must make the document scrollable"
|
||
|
||
# The user is reading at the very top ...
|
||
page.evaluate("() => window.scrollTo(0, 0)")
|
||
assert scroll_state(page)["y"] <= STABLE_PX
|
||
|
||
# ... and sends a question without first scrolling down.
|
||
submit_from_top(page, SHORT_QUESTION)
|
||
|
||
# The submit's reveal is the kept app scroll: the user's own message
|
||
# ends up in view (its box fully inside the viewport).
|
||
deadline = time.monotonic() + 10
|
||
while time.monotonic() < deadline and not user_message_in_view(page):
|
||
time.sleep(0.2)
|
||
assert user_message_in_view(page), (
|
||
"the submit must reveal the user's message — its box is not in the viewport"
|
||
)
|
||
|
||
# The turn completes; the answer lands off-screen below, but the user
|
||
# message stays revealed (nothing re-positions it).
|
||
wait_settled(page)
|
||
assert user_message_in_view(page)
|
||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. Restore landing (phase 14, owner-kept): a reload lands one-shot on
|
||
# the latest message and stays there while idle
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_restore_landing_one_shot(
|
||
page: Page, app_url: str, seeded_kb: None
|
||
) -> None:
|
||
page.set_default_timeout(30_000)
|
||
login(page, app_url, next="/")
|
||
|
||
# Settle a conversation (phase-14 persistence): long + short turns.
|
||
submit(page, LONG_QUESTION)
|
||
wait_settled(page)
|
||
submit(page, SHORT_QUESTION)
|
||
wait_settled(page)
|
||
time.sleep(0.5) # let the persistence writes land before the reload
|
||
|
||
page.reload()
|
||
# Restore re-renders from localStorage; wait until the last restored
|
||
# brain answer (the short one — the long answer carries no mock
|
||
# marker) is fully back.
|
||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
|
||
MOCK_ANSWER_MARKER, timeout=30_000
|
||
)
|
||
# The one-shot landing rides a smooth scroll — let it settle ...
|
||
wait_scroll_still(page)
|
||
state = scroll_state(page)
|
||
assert state["sh"] > state["ch"]
|
||
|
||
# ... and it lands on the latest message: its bubble is in view, in
|
||
# the lower half of the viewport (the chips + composer sit just
|
||
# below the fold — the landing predates them by design).
|
||
box = page.locator(".msg.brain").last.locator(".bubble").bounding_box()
|
||
assert box is not None
|
||
assert box["y"] < state["ch"] and box["y"] + box["height"] >= state["ch"] * 0.6, (
|
||
"the restore landing must put the latest message in view"
|
||
)
|
||
|
||
# ... and STAYS: idle samples (no stream active) never move.
|
||
samples = [state["y"]]
|
||
for _ in range(4):
|
||
time.sleep(0.4)
|
||
samples.append(scroll_state(page)["y"])
|
||
spread = max(samples) - min(samples)
|
||
assert spread <= STABLE_PX, (
|
||
f"the restored page moved {spread:.1f}px while idle"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 5. Content intact (regression): the long answer completes with sources;
|
||
# a thinking turn persists + restores its collapsed block (phase 17)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_answer_content_intact(page: Page, app_url: str, seeded_kb: None) -> None:
|
||
page.set_default_timeout(30_000)
|
||
login(page, app_url, next="/")
|
||
|
||
# The long answer streams to completion (phase 119, LOCKED A1: a
|
||
# zero-read turn chips nothing — the suggested kubernetes doc never
|
||
# chips; the retired phase-118 A4 union is gone) ...
|
||
submit(page, LONG_QUESTION)
|
||
wait_settled(page)
|
||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(LONG_ANSWER_END)
|
||
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
|
||
|
||
# ... and a thinking turn completes with its block auto-collapsed
|
||
# (phase 17: open while streaming, closed from the first delta on).
|
||
submit(page, THINKING_QUESTION)
|
||
wait_settled(page)
|
||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||
expect(details).not_to_have_attribute("open")
|
||
expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
|
||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
|
||
|
||
# Persistence: four messages, the thinking text stored raw — and
|
||
# the thinking turn's sources EMPTY (phase 119, LOCKED A1: it read
|
||
# nothing, so the done frame — and the record — carry no sources;
|
||
# the retired phase-118 A4 union is gone).
|
||
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
|
||
stored = json.loads(raw)
|
||
assert [m["who"] for m in stored["messages"]] == ["user", "brain", "user", "brain"]
|
||
assert LONG_ANSWER_END in stored["messages"][1]["text"]
|
||
assert THINKING_FRAGMENT in stored["messages"][3]["thinking"]
|
||
assert stored["messages"][3]["sources"] == []
|
||
|
||
# Restore: the long answer and the COLLAPSED thinking block come
|
||
# back intact — with ZERO citation chips (both turns read nothing;
|
||
# phase 119, LOCKED A1).
|
||
page.reload()
|
||
expect(page.locator(".msg.user .bubble")).to_have_count(2)
|
||
expect(page.locator(".msg.brain .bubble")).to_have_count(2)
|
||
expect(page.locator(".msg.brain .bubble").first).to_contain_text(LONG_ANSWER_END)
|
||
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
|
||
restored = page.locator(".msg.brain").last.locator("details.thinking")
|
||
expect(restored).not_to_have_attribute("open")
|
||
expect(restored.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
|
||
wait_settled(page)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 6. Regression (2026-08-29, owner report): pressing Enter to send must
|
||
# not scroll the page UP. The user is at the document bottom (having
|
||
# read the answer), sends a new question; the reveal may only move
|
||
# the viewport DOWN (the new message grows the page) — the old
|
||
# block:"end" alignment hopped it up by the composer+footer height
|
||
# and pushed the composer below the fold on every submit.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_submit_does_not_hop_up(
|
||
page: Page, app_url: str, seeded_kb: None
|
||
) -> None:
|
||
page.set_default_timeout(30_000)
|
||
login(page, app_url, next="/")
|
||
|
||
# A settled long answer (the document overflows the 800px viewport),
|
||
# and the user has scrolled to the very bottom to read it — the
|
||
# real-world position for the next Enter.
|
||
submit(page, LONG_QUESTION)
|
||
wait_settled(page)
|
||
page.evaluate("() => window.scrollTo(0, document.documentElement.scrollHeight)")
|
||
page.wait_for_timeout(150) # let the (user) scroll land
|
||
state = scroll_state(page)
|
||
assert state["sh"] > state["ch"], "a long answer must make the document scrollable"
|
||
assert state["y"] + state["ch"] >= state["sh"] - STABLE_PX, "at the bottom"
|
||
y0 = state["y"]
|
||
|
||
# Send the next question from the bottom. Sample the viewport from
|
||
# the moment the user bubble lands through the smooth reveal: the
|
||
# page must never move ABOVE where the user left it (no up-hop), and
|
||
# the reveal may only settle at or BELOW the starting position
|
||
# (the new message grows the page — a downward reveal). The old
|
||
# block:"end" alignment settled ~170px (composer+footer) ABOVE it.
|
||
page.fill("#message-input", SHORT_QUESTION)
|
||
page.click("#send-btn")
|
||
expect(page.locator(".msg.user .bubble").last).to_contain_text(SHORT_QUESTION)
|
||
|
||
samples: list[float] = []
|
||
prev: float | None = None
|
||
deadline = time.monotonic() + 10
|
||
while True:
|
||
y = scroll_state(page)["y"]
|
||
samples.append(y)
|
||
if prev is not None and abs(y - prev) <= STABLE_PX:
|
||
break # the reveal has settled (the only scroll in flight)
|
||
prev = y
|
||
if time.monotonic() >= deadline:
|
||
raise AssertionError("the submit reveal did not settle within timeout")
|
||
time.sleep(0.1)
|
||
|
||
assert min(samples) >= y0 - STABLE_PX, (
|
||
f"the submit hopped the page UP (y0={y0:.0f}, min={min(samples):.0f}) — "
|
||
"the reveal must land at the document bottom, never above the user"
|
||
)
|
||
assert samples[-1] >= y0 - STABLE_PX, (
|
||
f"the reveal settled ABOVE where the user was (y0={y0:.0f}, "
|
||
f"settled={samples[-1]:.0f}) — it must land at or below the start"
|
||
)
|
||
# The user's own message is revealed in view. (The composer sits in
|
||
# view at the settled position too — the reveal lands at the document
|
||
# bottom — but it is not asserted here: phase 42 (no page autoscroll)
|
||
# lets the in-flight reply push it down afterwards, so its exact box
|
||
# is a timing race, not a contract.)
|
||
assert user_message_in_view(page), "the submit must reveal the user's message"
|
||
|
||
# And the turn completes normally (nothing about the reveal changed
|
||
# the never-stale contract).
|
||
wait_settled(page)
|
||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
|