use super::{BrowserSession, access, humanize};
use crate::browser::{
BrowserError, BrowserOutput,
output::Ack,
request::{ClickTextRequest, FillRequest, KeyPressRequest, ToggleRequest, TypeRequest},
};
use chromiumoxide::page::Page;
use std::time::Duration;
use tokio::time::{Instant, sleep};
pub(super) async fn type_text(
session: &BrowserSession,
request: TypeRequest,
) -> Result<BrowserOutput, BrowserError> {
let page = access::current_page(session).await?;
let element = page.find_element(&request.selector).await?;
element.focus().await?;
humanize::settle_delay().await;
for ch in request.text.chars() {
element.type_str(ch.to_string()).await?;
if request.delay_ms == 0 {
humanize::keystroke_delay().await;
} else {
sleep(Duration::from_millis(request.delay_ms)).await;
}
}
Ok(BrowserOutput::Ack(Ack { ok: true }))
}
pub(super) async fn press(
session: &BrowserSession,
request: KeyPressRequest,
) -> Result<BrowserOutput, BrowserError> {
let page = access::current_page(session).await?;
let element = page.find_element(&request.selector).await?;
element.focus().await?.press_key(&request.key).await?;
Ok(BrowserOutput::Ack(Ack { ok: true }))
}
pub(super) async fn click_text(
session: &BrowserSession,
request: ClickTextRequest,
) -> Result<BrowserOutput, BrowserError> {
let page = access::current_page(session).await?;
let deadline = Instant::now() + Duration::from_millis(request.timeout_ms);
loop {
if locate_text(&page, &request).await? {
click_marked(&page).await?;
return Ok(BrowserOutput::Ack(Ack { ok: true }));
}
if Instant::now() >= deadline {
return Err(BrowserError::ElementNotFound(format!(
"no element with text {:?}",
request.text
)));
}
sleep(Duration::from_millis(100)).await;
}
}
pub(super) async fn toggle(
session: &BrowserSession,
request: ToggleRequest,
) -> Result<BrowserOutput, BrowserError> {
let _ = request.text;
let _ = request.timeout_ms;
let page = access::current_page(session).await?;
let selector_lit = serde_json::to_string(&request.selector)?;
let script = format!(
"(() => {{
const el = document.querySelector({selector_lit});
if (!el) return false;
if (el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {{
el.checked = !el.checked;
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
return true;
}}
el.click();
return true;
}})()"
);
let ok: bool = page.evaluate(script).await?.into_value()?;
if !ok {
return Err(BrowserError::ElementNotFound(request.selector));
}
Ok(BrowserOutput::Ack(Ack { ok: true }))
}
pub(super) async fn fill_native(
session: &BrowserSession,
request: FillRequest,
) -> Result<BrowserOutput, BrowserError> {
let page = access::current_page(session).await?;
let selector_lit = serde_json::to_string(&request.selector)?;
let value_lit = serde_json::to_string(&request.value)?;
let script = format!(
"(() => {{
const el = document.querySelector({selector_lit});
if (!el) return false;
const proto = Object.getPrototypeOf(el);
const setter = Object.getOwnPropertyDescriptor(proto, 'value');
if (setter && setter.set) {{
setter.set.call(el, {value_lit});
}} else {{
el.value = {value_lit};
}}
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
return true;
}})()"
);
let ok: bool = page.evaluate(script).await?.into_value()?;
if !ok {
return Err(BrowserError::ElementNotFound(request.selector));
}
Ok(BrowserOutput::Ack(Ack { ok: true }))
}
async fn locate_text(page: &Page, req: &ClickTextRequest) -> Result<bool, BrowserError> {
let scope_lit = serde_json::to_string(req.selector.as_deref().unwrap_or(""))?;
let text_lit = serde_json::to_string(&req.text)?;
let exact = req.exact;
let index = req.index;
let script = format!(
"(() => {{
const scope = {scope_lit} ? document.querySelector({scope_lit}) : document;
if (!scope) return false;
const want = {text_lit};
const exact = {exact};
const wantLower = want.toLowerCase();
const matches = [];
const candidates = scope.querySelectorAll('a, button, [role=\"button\"], [role=\"link\"], [role=\"tab\"], [role=\"menuitem\"], summary, label, [onclick]');
for (const el of candidates) {{
const txt = (el.innerText || el.textContent || '').trim();
const ok = exact ? txt === want : txt.toLowerCase().includes(wantLower);
if (ok) matches.push(el);
}}
if (matches.length === 0) {{
const walker = document.createTreeWalker(scope, NodeFilter.SHOW_ELEMENT);
while (walker.nextNode()) {{
const el = walker.currentNode;
const txt = (el.innerText || '').trim();
if (!txt) continue;
const ok = exact ? txt === want : txt.toLowerCase().includes(wantLower);
if (ok) matches.push(el);
}}
}}
const target = matches[{index}];
if (!target) return false;
target.scrollIntoView({{ block: 'center', inline: 'center' }});
document.querySelectorAll('[data-codetether-click-target]').forEach(el => el.removeAttribute('data-codetether-click-target'));
target.setAttribute('data-codetether-click-target', '1');
return true;
}})()"
);
Ok(page.evaluate(script).await?.into_value::<bool>().unwrap_or(false))
}
async fn click_marked(page: &Page) -> Result<(), BrowserError> {
let element = page.find_element("[data-codetether-click-target=\"1\"]").await?;
element.click().await?;
let _ = page
.evaluate(
"document.querySelectorAll('[data-codetether-click-target]').forEach(el => el.removeAttribute('data-codetether-click-target'))",
)
.await;
Ok(())
}