#![cfg(wasm)]
#![cfg(wasm)]
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use gloo_timers::future::TimeoutFuture;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Document, Element, Window};
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
pub const DEFAULT_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Debug, Clone)]
pub enum WaitError {
Timeout {
timeout: Duration,
description: Option<String>,
},
JsError(String),
ElementNotFound(String),
}
impl std::fmt::Display for WaitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WaitError::Timeout {
timeout,
description,
} => {
if let Some(desc) = description {
write!(f, "Timed out after {:?} waiting for: {}", timeout, desc)
} else {
write!(f, "Timed out after {:?}", timeout)
}
}
WaitError::JsError(msg) => write!(f, "JavaScript error: {}", msg),
WaitError::ElementNotFound(selector) => {
write!(f, "Element not found: {}", selector)
}
}
}
}
impl std::error::Error for WaitError {}
pub type WaitResult<T> = Result<T, WaitError>;
#[derive(Debug, Clone)]
pub struct WaitOptions {
pub timeout: Duration,
pub interval: Duration,
pub description: Option<String>,
}
impl Default for WaitOptions {
fn default() -> Self {
Self {
timeout: DEFAULT_TIMEOUT,
interval: DEFAULT_INTERVAL,
description: None,
}
}
}
impl WaitOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn with_interval(mut self, interval: Duration) -> Self {
self.interval = interval;
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
}
pub struct WaitForBuilder<F>
where
F: FnMut() -> bool,
{
condition: F,
options: WaitOptions,
}
impl<F> WaitForBuilder<F>
where
F: FnMut() -> bool,
{
pub fn new(condition: F) -> Self {
Self {
condition,
options: WaitOptions::default(),
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.options.timeout = timeout;
self
}
pub fn with_interval(mut self, interval: Duration) -> Self {
self.options.interval = interval;
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.options.description = Some(description.into());
self
}
pub async fn await_condition(mut self) -> WaitResult<()> {
let start = js_sys::Date::now();
let timeout_ms = self.options.timeout.as_millis() as f64;
let interval_ms = self.options.interval.as_millis() as u32;
loop {
if (self.condition)() {
return Ok(());
}
let elapsed = js_sys::Date::now() - start;
if elapsed >= timeout_ms {
return Err(WaitError::Timeout {
timeout: self.options.timeout,
description: self.options.description.clone(),
});
}
TimeoutFuture::new(interval_ms).await;
}
}
}
impl<F> std::future::IntoFuture for WaitForBuilder<F>
where
F: FnMut() -> bool + 'static,
{
type Output = WaitResult<()>;
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output>>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(self.await_condition())
}
}
pub fn wait_for<F>(condition: F) -> WaitForBuilder<F>
where
F: FnMut() -> bool,
{
WaitForBuilder::new(condition)
}
pub async fn wait_for_element_visible(
selector: &str,
options: Option<WaitOptions>,
) -> WaitResult<Element> {
let opts = options.unwrap_or_else(|| {
WaitOptions::default().with_description(format!("element '{}' to be visible", selector))
});
let selector_owned = selector.to_string();
let document = get_document()?;
let start = js_sys::Date::now();
let timeout_ms = opts.timeout.as_millis() as f64;
let interval_ms = opts.interval.as_millis() as u32;
loop {
if let Some(element) = document.query_selector(&selector_owned).ok().flatten() {
if is_element_visible(&element) {
return Ok(element);
}
}
let elapsed = js_sys::Date::now() - start;
if elapsed >= timeout_ms {
return Err(WaitError::Timeout {
timeout: opts.timeout,
description: opts.description.clone(),
});
}
TimeoutFuture::new(interval_ms).await;
}
}
pub async fn wait_for_element_hidden(
selector: &str,
options: Option<WaitOptions>,
) -> WaitResult<()> {
let opts = options.unwrap_or_else(|| {
WaitOptions::default().with_description(format!("element '{}' to be hidden", selector))
});
let selector_owned = selector.to_string();
let document = get_document()?;
let start = js_sys::Date::now();
let timeout_ms = opts.timeout.as_millis() as f64;
let interval_ms = opts.interval.as_millis() as u32;
loop {
let element = document.query_selector(&selector_owned).ok().flatten();
if element.is_none() || !is_element_visible(element.as_ref().unwrap()) {
return Ok(());
}
let elapsed = js_sys::Date::now() - start;
if elapsed >= timeout_ms {
return Err(WaitError::Timeout {
timeout: opts.timeout,
description: opts.description.clone(),
});
}
TimeoutFuture::new(interval_ms).await;
}
}
pub async fn wait_for_element(selector: &str, options: Option<WaitOptions>) -> WaitResult<Element> {
let opts = options.unwrap_or_else(|| {
WaitOptions::default().with_description(format!("element '{}' to appear in DOM", selector))
});
let selector_owned = selector.to_string();
let document = get_document()?;
let start = js_sys::Date::now();
let timeout_ms = opts.timeout.as_millis() as f64;
let interval_ms = opts.interval.as_millis() as u32;
loop {
if let Some(element) = document.query_selector(&selector_owned).ok().flatten() {
return Ok(element);
}
let elapsed = js_sys::Date::now() - start;
if elapsed >= timeout_ms {
return Err(WaitError::Timeout {
timeout: opts.timeout,
description: opts.description.clone(),
});
}
TimeoutFuture::new(interval_ms).await;
}
}
pub async fn wait_for_element_removed(
selector: &str,
options: Option<WaitOptions>,
) -> WaitResult<()> {
let opts = options.unwrap_or_else(|| {
WaitOptions::default()
.with_description(format!("element '{}' to be removed from DOM", selector))
});
let selector_owned = selector.to_string();
let document = get_document()?;
let start = js_sys::Date::now();
let timeout_ms = opts.timeout.as_millis() as f64;
let interval_ms = opts.interval.as_millis() as u32;
loop {
if document
.query_selector(&selector_owned)
.ok()
.flatten()
.is_none()
{
return Ok(());
}
let elapsed = js_sys::Date::now() - start;
if elapsed >= timeout_ms {
return Err(WaitError::Timeout {
timeout: opts.timeout,
description: opts.description.clone(),
});
}
TimeoutFuture::new(interval_ms).await;
}
}
pub async fn sleep(duration: Duration) {
TimeoutFuture::new(duration.as_millis() as u32).await;
}
pub async fn flush_microtasks() {
let promise = js_sys::Promise::resolve(&JsValue::UNDEFINED);
let _ = JsFuture::from(promise).await;
}
pub async fn flush_effects() {
flush_microtasks().await;
request_animation_frame().await;
flush_microtasks().await;
}
pub async fn request_animation_frame() {
let window = get_window().expect("window should be available in WASM environment");
let promise = js_sys::Promise::new(&mut |resolve, _reject| {
let closure = Closure::once_into_js(move || {
resolve
.call0(&JsValue::UNDEFINED)
.expect("Promise resolve callback should not fail");
});
window
.request_animation_frame(closure.unchecked_ref())
.expect("requestAnimationFrame should be available in browser environment");
});
let _ = JsFuture::from(promise).await;
}
pub async fn wait_frames(count: u32) {
for _ in 0..count {
request_animation_frame().await;
}
}
pub async fn wait_for_dom_stable(
stability_duration: Duration,
timeout: Duration,
) -> WaitResult<()> {
let start = js_sys::Date::now();
let timeout_ms = timeout.as_millis() as f64;
let stability_ms = stability_duration.as_millis() as u32;
let document = get_document()?;
let mut last_content = get_body_content(&document);
let mut stable_since = js_sys::Date::now();
loop {
let current_content = get_body_content(&document);
if current_content != last_content {
last_content = current_content;
stable_since = js_sys::Date::now();
} else {
let stable_duration = js_sys::Date::now() - stable_since;
if stable_duration >= stability_ms as f64 {
return Ok(());
}
}
let elapsed = js_sys::Date::now() - start;
if elapsed >= timeout_ms {
return Err(WaitError::Timeout {
timeout,
description: Some("DOM to stabilize".to_string()),
});
}
TimeoutFuture::new(16).await; }
}
fn get_window() -> WaitResult<Window> {
web_sys::window().ok_or_else(|| WaitError::JsError("Window not available".to_string()))
}
fn get_document() -> WaitResult<Document> {
get_window()?
.document()
.ok_or_else(|| WaitError::JsError("Document not available".to_string()))
}
fn is_element_visible(element: &Element) -> bool {
if let Some(html_element) = element.dyn_ref::<web_sys::HtmlElement>() {
if html_element.offset_parent().is_none() {
if let Ok(style) = get_window()
.ok()
.and_then(|w| w.get_computed_style(element).ok())
.flatten()
.ok_or(())
{
let position = style.get_property_value("position").unwrap_or_default();
if position != "fixed" && position != "sticky" {
return false;
}
} else {
return false;
}
}
if let Some(style) = get_window()
.ok()
.and_then(|w| w.get_computed_style(element).ok())
.flatten()
{
let visibility: String = style.get_property_value("visibility").unwrap_or_default();
let display: String = style.get_property_value("display").unwrap_or_default();
if visibility == "hidden" || display == "none" {
return false;
}
}
return true;
}
true
}
fn get_body_content(document: &Document) -> String {
document.body().map(|b| b.inner_html()).unwrap_or_default()
}
pub trait ElementWaitExt {
fn wait_until_visible(&self, options: Option<WaitOptions>) -> WaitForVisibleFuture;
fn wait_until_hidden(&self, options: Option<WaitOptions>) -> WaitForHiddenFuture;
}
impl ElementWaitExt for Element {
fn wait_until_visible(&self, options: Option<WaitOptions>) -> WaitForVisibleFuture {
WaitForVisibleFuture {
element: self.clone(),
options: options.unwrap_or_default(),
started: false,
start_time: 0.0,
pending_closure: None,
}
}
fn wait_until_hidden(&self, options: Option<WaitOptions>) -> WaitForHiddenFuture {
WaitForHiddenFuture {
element: self.clone(),
options: options.unwrap_or_default(),
started: false,
start_time: 0.0,
pending_closure: None,
}
}
}
pub struct WaitForVisibleFuture {
element: Element,
options: WaitOptions,
started: bool,
start_time: f64,
pending_closure: Option<Closure<dyn FnMut()>>,
}
impl Future for WaitForVisibleFuture {
type Output = WaitResult<()>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if !self.started {
self.started = true;
self.start_time = js_sys::Date::now();
}
if is_element_visible(&self.element) {
return Poll::Ready(Ok(()));
}
let elapsed = js_sys::Date::now() - self.start_time;
if elapsed >= self.options.timeout.as_millis() as f64 {
return Poll::Ready(Err(WaitError::Timeout {
timeout: self.options.timeout,
description: self.options.description.clone(),
}));
}
let waker = cx.waker().clone();
let interval_ms = self.options.interval.as_millis() as i32;
let closure = Closure::once(move || {
waker.wake();
});
if let Ok(window) = get_window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
closure.as_ref().unchecked_ref(),
interval_ms,
);
}
self.pending_closure = Some(closure);
Poll::Pending
}
}
pub struct WaitForHiddenFuture {
element: Element,
options: WaitOptions,
started: bool,
start_time: f64,
pending_closure: Option<Closure<dyn FnMut()>>,
}
impl Future for WaitForHiddenFuture {
type Output = WaitResult<()>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if !self.started {
self.started = true;
self.start_time = js_sys::Date::now();
}
if !is_element_visible(&self.element) {
return Poll::Ready(Ok(()));
}
let elapsed = js_sys::Date::now() - self.start_time;
if elapsed >= self.options.timeout.as_millis() as f64 {
return Poll::Ready(Err(WaitError::Timeout {
timeout: self.options.timeout,
description: self.options.description.clone(),
}));
}
let waker = cx.waker().clone();
let interval_ms = self.options.interval.as_millis() as i32;
let closure = Closure::once(move || {
waker.wake();
});
if let Ok(window) = get_window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
closure.as_ref().unchecked_ref(),
interval_ms,
);
}
self.pending_closure = Some(closure);
Poll::Pending
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
fn test_wait_options_default_values() {
let opts = WaitOptions::default();
assert_eq!(opts.timeout, Duration::from_secs(5));
assert_eq!(opts.interval, Duration::from_millis(50));
assert_eq!(opts.description, None);
}
#[rstest]
fn test_wait_options_new_equals_default() {
let from_new = WaitOptions::new();
let from_default = WaitOptions::default();
assert_eq!(from_new.timeout, from_default.timeout);
assert_eq!(from_new.interval, from_default.interval);
assert_eq!(from_new.description, from_default.description);
}
#[rstest]
fn test_wait_options_with_timeout() {
let opts = WaitOptions::new().with_timeout(Duration::from_secs(30));
assert_eq!(opts.timeout, Duration::from_secs(30));
assert_eq!(opts.interval, Duration::from_millis(50));
assert_eq!(opts.description, None);
}
#[rstest]
fn test_wait_options_with_interval() {
let opts = WaitOptions::new().with_interval(Duration::from_millis(200));
assert_eq!(opts.interval, Duration::from_millis(200));
assert_eq!(opts.timeout, Duration::from_secs(5));
assert_eq!(opts.description, None);
}
#[rstest]
fn test_wait_options_with_description() {
let opts = WaitOptions::new().with_description("loading spinner");
assert_eq!(opts.description, Some("loading spinner".to_string()));
assert_eq!(opts.timeout, Duration::from_secs(5));
assert_eq!(opts.interval, Duration::from_millis(50));
}
#[rstest]
fn test_wait_options_builder_chaining() {
let opts = WaitOptions::new()
.with_timeout(Duration::from_secs(10))
.with_interval(Duration::from_millis(100))
.with_description("test wait");
assert_eq!(opts.timeout, Duration::from_secs(10));
assert_eq!(opts.interval, Duration::from_millis(100));
assert_eq!(opts.description, Some("test wait".to_string()));
}
#[rstest]
fn test_wait_error_timeout_with_description() {
let error = WaitError::Timeout {
timeout: Duration::from_secs(5),
description: Some("element to appear".to_string()),
};
let msg = error.to_string();
assert!(msg.contains("5s"));
assert!(msg.contains("element to appear"));
assert!(msg.contains("Timed out"));
}
#[rstest]
fn test_wait_error_timeout_without_description() {
let error = WaitError::Timeout {
timeout: Duration::from_secs(3),
description: None,
};
let msg = error.to_string();
assert!(msg.contains("3s"));
assert!(msg.contains("Timed out"));
assert!(!msg.contains("waiting for:"));
}
#[rstest]
fn test_wait_error_js_error_display() {
let error = WaitError::JsError("TypeError: undefined is not a function".to_string());
let msg = error.to_string();
assert!(msg.contains("JavaScript error"));
assert!(msg.contains("TypeError: undefined is not a function"));
}
#[rstest]
fn test_wait_error_element_not_found_display() {
let error = WaitError::ElementNotFound("#missing-element".to_string());
let msg = error.to_string();
assert!(msg.contains("Element not found"));
assert!(msg.contains("#missing-element"));
}
#[rstest]
fn test_wait_error_implements_std_error() {
let error = WaitError::JsError("test".to_string());
let std_error: &dyn std::error::Error = &error;
assert!(std_error.source().is_none());
}
#[rstest]
fn test_default_timeout_constant() {
assert_eq!(DEFAULT_TIMEOUT, Duration::from_secs(5));
}
#[rstest]
fn test_default_interval_constant() {
assert_eq!(DEFAULT_INTERVAL, Duration::from_millis(50));
}
}