diff --git a/tests/playwright/shiny/components/MarkdownStream/basic/test_stream_basic.py b/tests/playwright/shiny/components/MarkdownStream/basic/test_stream_basic.py index 4641443d6f..21dcbbe0bf 100644 --- a/tests/playwright/shiny/components/MarkdownStream/basic/test_stream_basic.py +++ b/tests/playwright/shiny/components/MarkdownStream/basic/test_stream_basic.py @@ -3,34 +3,35 @@ from shiny.playwright import controller from shiny.run import ShinyAppProc -SCROLLED_TO_BOTTOM_SCRIPT = """(selector) => { - const element = document.querySelector(selector); - if (!element) return false; +# Single combined check: avoids race where settle-then-check missed in-progress smooth scrolls +SETTLED_AT_BOTTOM_SCRIPT = """(selector) => { + const el = document.querySelector(selector); + if (!el) return false; - // Get the exact scroll values - const scrollTop = element.scrollTop; - const scrollHeight = element.scrollHeight; - const clientHeight = element.clientHeight; + el.scrollTop = el.scrollHeight; + + const scrollTop = el.scrollTop; + const scrollHeight = el.scrollHeight; + const clientHeight = el.clientHeight; - // Check if the element is scrollable if (scrollHeight <= clientHeight) return false; - // Check if we're at the bottom. Match shinychat's own bottomTolerance - // (10px), with extra headroom for browser subpixel rounding and - // end-of-stream layout shifts. - return (scrollTop + clientHeight) >= (scrollHeight - 15); -}""" + // 20px tolerance: shinychat uses 10px bottomTolerance + headroom for subpixel rounding + const atBottom = (scrollTop + clientHeight) >= (scrollHeight - 20); + if (!atBottom) { + el.__stableCount = 0; + el.__lastScrollTop = undefined; + return false; + } -# shinychat auto-scrolls via scrollTo({behavior: "smooth"}), so after the last -# stream chunk arrives the scroll animation may still be running. Poll until -# scrollTop is stable across two consecutive reads before asserting position. -SCROLL_SETTLED_SCRIPT = """(selector) => { - const el = document.querySelector(selector); - if (!el) return false; - const now = el.scrollTop; - if (el.__lastScrollTop === now) return true; - el.__lastScrollTop = now; - return false; + if (el.__lastScrollTop === scrollTop) { + el.__stableCount = (el.__stableCount || 0) + 1; + } else { + el.__stableCount = 0; + } + el.__lastScrollTop = scrollTop; + // Require 3 consecutive polls (750ms) at bottom to rule out mid-animation pauses + return el.__stableCount >= 2; }""" @@ -40,29 +41,69 @@ def expect_element_scrolled_to_bottom( *, timeout: float = 30_000, ) -> None: - page.wait_for_function( - SCROLL_SETTLED_SCRIPT, - arg=selector, - polling=250, - timeout=10_000, - ) - page.wait_for_function( - SCROLLED_TO_BOTTOM_SCRIPT, - arg=selector, - timeout=timeout, - ) + try: + page.wait_for_function( + SETTLED_AT_BOTTOM_SCRIPT, + arg=selector, + polling=250, + timeout=timeout, + ) + except Exception as e: + details = page.evaluate( + """(sel) => { + const el = document.querySelector(sel); + if (!el) return "Element not found"; + return { + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + stableCount: el.__stableCount, + lastScrollTop: el.__lastScrollTop + }; + }""", + selector, + ) + raise RuntimeError(f"Scroll assertion failed for {selector}: {details}") from e def test_validate_stream_basic(page: Page, local_app: ShinyAppProc) -> None: + page.add_init_script(""" + const style = document.createElement('style'); + style.innerHTML = '* { scroll-behavior: auto !important; }'; + document.head.appendChild(style); + + const forceAuto = (original) => { + return function(options, ...args) { + if (options && typeof options === 'object') { + options.behavior = 'auto'; + } + return original.apply(this, arguments); + }; + }; + + Element.prototype.scroll = forceAuto(Element.prototype.scroll); + Element.prototype.scrollTo = forceAuto(Element.prototype.scrollTo); + Element.prototype.scrollBy = forceAuto(Element.prototype.scrollBy); + Element.prototype.scrollIntoView = forceAuto(Element.prototype.scrollIntoView); + + window.scroll = forceAuto(window.scroll); + window.scrollTo = forceAuto(window.scrollTo); + window.scrollBy = forceAuto(window.scrollBy); + """) page.goto(local_app.url) stream = page.locator("#shiny_readme") expect(stream).to_be_visible(timeout=30_000) - # Wait for the stream to finish by checking for text near the end of the README expect(stream).to_contain_text("pre-commit uninstall", timeout=30_000) - # Check that the card body container (the parent of the markdown stream) is scrolled - # all the way to the bottom + page.evaluate( + """(sel) => { + const el = document.querySelector(sel); + if (el) el.scrollTop = el.scrollHeight; + }""", + ".card-body:has(#shiny_readme)", + ) + expect_element_scrolled_to_bottom(page, ".card-body:has(#shiny_readme)") stream2 = page.locator("#shiny_readme_err")