Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}"""


Expand All @@ -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")
Expand Down
Loading