"""Unit: the no-reply-autoscroll contract in the static frontend (phase 42, owner direction 2026-08-27, TODO.md L5). The owner removed the phase-18 follow-the-bottom auto-follow: the page NEVER auto-scrolls while a turn streams (thinking / tool / delta frames all leave the viewport alone), so a user reading earlier content is no longer yanked down mid-answer. Scrolls happen only on explicit user intent: the submit (the user's own message is revealed) and the phase-14 restore landing (one-shot, load-time). The JS behavior itself is E2E-covered (tests/e2e/test_no_reply_autoscroll.py); here we pin the source markers of the new contract — the phase-18 gate is gone (no NEAR_BOTTOM_PX / isNearBottom), scrollReveal scrolls unconditionally and is the single scrollIntoView in app.js, addMessage takes an explicit `scroll` intent, and the streaming handlers contain no page-scroll call at all — so a silent regression back to per-frame autoscroll is caught without a browser. """ from __future__ import annotations import re from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" APP_JS = FRONTEND / "assets" / "app.js" def _js() -> str: return APP_JS.read_text(encoding="utf-8") def _fn_body(js: str, name: str) -> str: """Source of the function starting at `function ` (to its closing brace at column 0) — same slicing style as test_frontend_feedback.py.""" fn = js.find(f"function {name}") assert fn != -1, f"{name} must exist in app.js" return js[fn : js.find("\n}\n", fn)] def test_phase18_gate_is_gone() -> None: """The follow-the-bottom machinery (phase 18) is removed by owner direction 2026-08-27: no page-level band constant, no gate function, and no document-scroller measurement left anywhere in app.js. (The phase-43 window-level THINKING_NEAR_BOTTOM_PX / isThinkingNearBottom pair is a different band — the thinking window's, not the page's.)""" js = _js() assert not re.search(r"(? None: """scrollReveal is still the ONE scrollIntoView in app.js, but it now scrolls unconditionally — no force-or-near-bottom condition in its body, and the phase-18 `force` parameter is gone. A page scroll can only ever happen where scrollReveal is CALLED (submit + restore).""" js = _js() body = _fn_body(js, "scrollReveal") assert "scrollIntoView" in body assert 'block: "end"' in body assert "if (" not in body, "the helper must have no gate — it scrolls when called" assert "force" not in body, "the phase-18 force parameter must be gone" # Still smooth / reduced-motion-aware through the default behavior. assert "behavior = SCROLL" in body # The regression pin: exactly one actual scrollIntoView CALL in the # whole file, and it lives inside scrollReveal (the word may appear # in comments; the call must not). assert js.count(".scrollIntoView(") == 1, ( "app.js must call scrollIntoView exactly once (inside scrollReveal)" ) assert js.find(".scrollIntoView(") > js.find("function scrollReveal") def test_add_message_takes_explicit_scroll_intent() -> None: """addMessage(who, html, scroll = false): the phase-18 scrollBehavior/force parameters are gone; the bubble scrolls only when the caller explicitly asks (submit reveal, restore landing).""" js = _js() body = _fn_body(js, "addMessage") assert "function addMessage(who, html, scroll = false)" in body assert "if (scroll) scrollReveal(wrap)" in body assert "force" not in body assert "scrollBehavior" not in body def test_submit_reveals_user_message() -> None: """User intent kept by the owner: submitting scrolls the viewport down so the user's own message is visible — the submit addMessage passes the scroll intent; the streaming brain-bubble creations in the same function never do.""" js = _js() send = js.find("async function handleSend") assert send != -1, "handleSend must exist" body = js[send : js.find("\n}\n", send)] assert 'addMessage("user", renderMarkdown(text), true)' in body, ( "the submit must reveal the user message (scroll intent true)" ) for call in re.findall(r'addMessage\("brain"([^)]*)\)', body): assert "true" not in call, ( f"streaming brain bubbles must not scroll the page: {call!r}" ) def test_streaming_handlers_never_scroll_the_page() -> None: """The heart of the phase-42 contract: the thinking / tool / delta branches contain NO page-scroll call (no scrollReveal, no raw scrollIntoView). The thinking branch keeps the block-INTERNAL pin (textEl.scrollTop — phase 17, reworked in phase 43): that scrolls the block's own clip, not the page.""" js = _js() think = js.find('ev.type === "thinking"') tool = js.find('ev.type === "tool"') delta = js.find('ev.type === "delta"') done = js.find('ev.type === "done"') assert -1 < think < tool < delta < done, "the turn handler must branch in order" for name, branch in ( ("thinking", js[think:tool]), ("tool", js[tool:delta]), ("delta", js[delta:done]), ): assert "scrollReveal" not in branch, f"the {name} branch must not scroll the page" assert ".scrollIntoView(" not in branch, ( f"the {name} branch must not scroll the page" ) # The thinking window pin survives (phase 17 — untouched by this phase). thinking_branch = js[think:delta] assert "textEl.scrollTop = textEl.scrollHeight" in thinking_branch assert js.count("textEl.scrollTop = textEl.scrollHeight") == 1 def test_restore_landing_is_one_shot() -> None: """The phase-14 restore landing keeps its one-shot scroll (owner-kept): both restore call sites pass the explicit scroll intent and they are the only two restore scrolls; with the submit's single reveal, exactly three `true` intents exist in the whole file. The old forced "auto" landing is gone, and the one-shot, load-time contract is documented at the call site.""" js = _js() body = _fn_body(js, "renderStoredMessage") assert 'addMessage("user", renderMarkdown(m.text), true)' in body assert 'addMessage("brain", renderMarkdown(m.text), true)' in body assert js.count('"auto", true') == 0, "the old forced 'auto' landing must be gone" # Submit reveal + the two restore landings — nothing else scrolls. assert js.count(", true)") == 3, "only submit + the two restore calls may scroll" # The marker comment documents the one-shot, load-time contract. assert "restore landing" in body assert "one-shot" in body def test_typing_bubble_does_not_scroll() -> None: """A typing indicator appearing must not yank the page — the phase-18 scrollReveal call in addTyping is removed with the gate.""" js = _js() body = _fn_body(js, "addTyping") assert "scrollReveal" not in body assert ".scrollIntoView(" not in body def test_scroll_constant_reduced_motion_intact() -> None: """The SCROLL constant is untouched (calm, don't remove): smooth by default, "auto" under prefers-reduced-motion — the two kept scroll call sites ride it as the default behavior.""" js = _js() assert 'const SCROLL = reducedMotion ? "auto" : "smooth";' in js assert 'matchMedia("(prefers-reduced-motion: reduce)")' in js assert "Calm, don't remove" in js def test_turn_end_focus_does_not_scroll() -> None: """The turn-end focus-back (phase 06's "always focus back") must not move the viewport: focusing the composer while the user is scrolled up would yank them to the bottom at the moment the turn ends. preventScroll keeps the keyboard flow without the scroll.""" js = _js() finally_idx = js.find("// done | error → idle: always settle, always focus back") assert finally_idx != -1, "the turn's finally block must exist" block = js[finally_idx : js.find("\n}", finally_idx)] assert 'input.focus({ preventScroll: true })' in block assert "input.focus()" not in block