use super::{BrowserSession, access};
use crate::browser::{BrowserError, BrowserOutput, output::Ack, request::WaitRequest};
use chromiumoxide::error::CdpError;
use chromiumoxide::page::Page;
use std::time::Duration;
use tokio::time::Instant;
pub(super) async fn for_selector(
session: &BrowserSession,
request: WaitRequest,
) -> Result<BrowserOutput, BrowserError> {
if request
.frame_selector
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
return Err(BrowserError::OperationFailed(
"frame-scoped waits are not implemented yet".into(),
));
}
let timeout_ms = request.timeout_ms;
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
let page = access::current_page(session).await?;
if let Some(text) = request.text.as_deref() {
return wait_text(&page, request.selector.as_deref(), text, true, deadline, timeout_ms)
.await;
}
if let Some(text) = request.text_gone.as_deref() {
return wait_text(&page, request.selector.as_deref(), text, false, deadline, timeout_ms)
.await;
}
if let Some(needle) = request.url_contains.as_deref() {
return wait_url(&page, needle, deadline, timeout_ms).await;
}
if let Some(selector) = request.selector.as_deref() {
return wait_selector(&page, selector, &request.state, deadline, timeout_ms).await;
}
match request.state.as_str() {
"idle" | "network_idle" | "networkidle" => {
return wait_network_idle(&page, deadline, timeout_ms).await;
}
"ready" | "domcontentloaded" => {
return wait_ready_state(&page, &["interactive", "complete"], deadline, timeout_ms)
.await;
}
"load" | "complete" => {
return wait_ready_state(&page, &["complete"], deadline, timeout_ms).await;
}
_ => {}
}
Err(BrowserError::OperationFailed(
"wait requires one of: selector, text, text_gone, url_contains, or state in {idle, ready, load}".into(),
))
}
async fn wait_selector(
page: &Page,
selector: &str,
state: &str,
deadline: Instant,
timeout_ms: u64,
) -> Result<BrowserOutput, BrowserError> {
let want_present = matches!(state, "" | "attached" | "visible");
let want_visible = state == "visible" || state == "hidden";
let invert_visible = state == "hidden" || state == "detached";
loop {
let result = page.find_element(selector).await;
let satisfied = match (&result, want_present) {
(Ok(_element), true) => {
if want_visible {
let visible = is_visible(page, selector).await.unwrap_or(false);
if invert_visible { !visible } else { visible }
} else {
true
}
}
(Err(CdpError::NotFound), false) => true,
(Err(CdpError::NotFound), true) => false,
(Ok(_), false) => {
if want_visible {
let visible = is_visible(page, selector).await.unwrap_or(false);
!visible
} else {
false
}
}
(Err(error), _) => return Err(clone_cdp_error(error).into()),
};
if satisfied {
return Ok(BrowserOutput::Ack(Ack { ok: true }));
}
if Instant::now() >= deadline {
return Err(BrowserError::WaitTimeout {
selector: selector.to_string(),
timeout_ms,
});
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
fn clone_cdp_error(error: &CdpError) -> CdpError {
CdpError::msg(error.to_string())
}
async fn is_visible(page: &Page, selector: &str) -> Result<bool, BrowserError> {
let lit = serde_json::to_string(selector)?;
let script = format!(
"(() => {{
const el = document.querySelector({lit});
if (!el) return false;
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return false;
const s = getComputedStyle(el);
return s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity) > 0;
}})()"
);
Ok(page.evaluate(script).await?.into_value::<bool>().unwrap_or(false))
}
async fn wait_text(
page: &Page,
scope: Option<&str>,
needle: &str,
want_present: bool,
deadline: Instant,
timeout_ms: u64,
) -> Result<BrowserOutput, BrowserError> {
let scope_lit = serde_json::to_string(scope.unwrap_or(""))?;
let needle_lit = serde_json::to_string(needle)?;
if want_present {
let install = format!(
"(() => {{
window.__codetether_seen_text = window.__codetether_seen_text || {{}};
if (window.__codetether_seen_text[{needle_lit}]) return true;
const root = {scope_lit} ? document.querySelector({scope_lit}) : document.body;
if (!root) return false;
// Already visible in the DOM right now?
const current = (root.innerText || root.textContent || '');
if (current.includes({needle_lit})) {{
window.__codetether_seen_text[{needle_lit}] = true;
return true;
}}
// Install an observer (only once per needle).
const key = '__codetether_observer_' + btoa(unescape(encodeURIComponent({needle_lit}))).replace(/=/g,'');
if (!window[key]) {{
const obs = new MutationObserver(() => {{
try {{
const r = {scope_lit} ? document.querySelector({scope_lit}) : document.body;
if (!r) return;
const t = (r.innerText || r.textContent || '');
if (t.includes({needle_lit})) {{
window.__codetether_seen_text[{needle_lit}] = true;
}}
}} catch (_) {{ /* ignore */ }}
}});
obs.observe(document.body, {{ childList: true, subtree: true, characterData: true }});
window[key] = obs;
}}
return false;
}})()"
);
let _ = page.evaluate(install.as_str()).await;
}
let probe = format!(
"(() => {{
const seen = (window.__codetether_seen_text || {{}})[{needle_lit}] === true;
if (seen) return true;
const root = {scope_lit} ? document.querySelector({scope_lit}) : document.body;
if (!root) return false;
const t = (root.innerText || root.textContent || '');
return t.includes({needle_lit});
}})()"
);
loop {
let present = page
.evaluate(probe.as_str())
.await?
.into_value::<bool>()
.unwrap_or(false);
if present == want_present {
return Ok(BrowserOutput::Ack(Ack { ok: true }));
}
if Instant::now() >= deadline {
return Err(BrowserError::WaitTimeout {
selector: format!("text={needle:?}"),
timeout_ms,
});
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
async fn wait_url(
page: &Page,
needle: &str,
deadline: Instant,
timeout_ms: u64,
) -> Result<BrowserOutput, BrowserError> {
loop {
let url = page.url().await?.unwrap_or_default();
if url.contains(needle) {
return Ok(BrowserOutput::Ack(Ack { ok: true }));
}
if Instant::now() >= deadline {
return Err(BrowserError::WaitTimeout {
selector: format!("url~={needle:?}"),
timeout_ms,
});
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
async fn wait_network_idle(
page: &Page,
deadline: Instant,
timeout_ms: u64,
) -> Result<BrowserOutput, BrowserError> {
const QUIET_MS: u64 = 500;
let install = r#"
(() => {
if (window.__codetether_netidle_installed) return true;
window.__codetether_netidle_installed = true;
window.__codetether_in_flight = 0;
window.__codetether_last_change = Date.now();
const bump = (delta) => {
window.__codetether_in_flight = Math.max(0, window.__codetether_in_flight + delta);
window.__codetether_last_change = Date.now();
};
const origFetch = window.fetch;
if (origFetch) {
window.fetch = function(...args) {
bump(1);
return origFetch.apply(this, args).finally(() => bump(-1));
};
}
const XHR = window.XMLHttpRequest;
if (XHR && XHR.prototype && XHR.prototype.send) {
const origSend = XHR.prototype.send;
XHR.prototype.send = function(...args) {
bump(1);
const done = () => bump(-1);
this.addEventListener('loadend', done, { once: true });
this.addEventListener('abort', done, { once: true });
this.addEventListener('error', done, { once: true });
return origSend.apply(this, args);
};
}
return true;
})()
"#;
let _ = page.evaluate(install).await;
let probe = format!(
"(() => ({{
in_flight: window.__codetether_in_flight || 0,
quiet_ms: Date.now() - (window.__codetether_last_change || Date.now())
}}))()"
);
loop {
let snapshot = page
.evaluate(probe.as_str())
.await?
.into_value::<serde_json::Value>()
.unwrap_or_default();
let in_flight = snapshot
.get("in_flight")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let quiet_ms = snapshot
.get("quiet_ms")
.and_then(|v| v.as_i64())
.unwrap_or(0);
if in_flight <= 0 && quiet_ms >= QUIET_MS as i64 {
return Ok(BrowserOutput::Ack(Ack { ok: true }));
}
if Instant::now() >= deadline {
return Err(BrowserError::WaitTimeout {
selector: "network_idle".into(),
timeout_ms,
});
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
async fn wait_ready_state(
page: &Page,
accept: &[&str],
deadline: Instant,
timeout_ms: u64,
) -> Result<BrowserOutput, BrowserError> {
loop {
let state = page
.evaluate("document.readyState")
.await?
.into_value::<String>()
.unwrap_or_default();
if accept.iter().any(|want| state == *want) {
return Ok(BrowserOutput::Ack(Ack { ok: true }));
}
if Instant::now() >= deadline {
return Err(BrowserError::WaitTimeout {
selector: format!("readyState in {accept:?}"),
timeout_ms,
});
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}