use std::path::PathBuf;
pub fn find_browser_binary(candidates: &[PathBuf]) -> Option<PathBuf> {
candidates.iter().find(|p| p.exists()).cloned()
}
pub fn default_candidates() -> Vec<PathBuf> {
let mut v = Vec::new();
let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".into());
let pf86 =
std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".into());
let local = std::env::var("LOCALAPPDATA").unwrap_or_default();
v.push(PathBuf::from(&pf).join(r"Google\Chrome\Application\chrome.exe"));
v.push(PathBuf::from(&pf86).join(r"Google\Chrome\Application\chrome.exe"));
if !local.is_empty() {
v.push(PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe"));
}
v.push(PathBuf::from(&pf86).join(r"Microsoft\Edge\Application\msedge.exe"));
v.push(PathBuf::from(&pf).join(r"Microsoft\Edge\Application\msedge.exe"));
v
}
pub fn is_allowed_url(url: &str) -> bool {
let lower = url.trim().to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
#[derive(Debug, Clone, PartialEq)]
pub struct AxSnapshotNode {
pub node_id: String,
pub role: String,
pub name: Option<String>,
pub states: Vec<String>,
pub child_ids: Vec<String>,
pub backend_node_id: Option<i64>,
}
impl AxSnapshotNode {
fn est_decoratif(&self) -> bool {
(self.role == "none" || self.role == "ignored" || self.role.is_empty())
&& self.name.is_none()
}
}
const PROFONDEUR_MAX: usize = 1000;
pub fn render_ax_tree(nodes: &[AxSnapshotNode]) -> (String, Vec<(String, i64)>) {
use std::collections::{HashMap, HashSet};
if nodes.is_empty() {
return (String::new(), Vec::new());
}
let index: HashMap<&str, &AxSnapshotNode> =
nodes.iter().map(|n| (n.node_id.as_str(), n)).collect();
let mut out = String::new();
let mut visites: HashSet<&str> = HashSet::new();
let mut compteur: usize = 0;
let mut refs: Vec<(String, i64)> = Vec::new();
render_node(
&nodes[0],
&index,
0,
0,
&mut out,
&mut visites,
&mut compteur,
&mut refs,
);
(out.trim_end().to_string(), refs)
}
fn nettoyer(s: &str) -> String {
s.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect()
}
#[allow(clippy::too_many_arguments)]
fn render_node<'a>(
node: &'a AxSnapshotNode,
index: &std::collections::HashMap<&'a str, &'a AxSnapshotNode>,
depth: usize,
recursion: usize,
out: &mut String,
visites: &mut std::collections::HashSet<&'a str>,
compteur: &mut usize,
refs: &mut Vec<(String, i64)>,
) {
if !visites.insert(node.node_id.as_str()) {
return;
}
if recursion >= PROFONDEUR_MAX {
return;
}
if !node.est_decoratif() {
for _ in 0..depth {
out.push_str(" ");
}
if let Some(bid) = node.backend_node_id {
*compteur += 1;
let r = format!("e{compteur}");
out.push_str(&format!("[ref={r}] "));
refs.push((r, bid));
}
out.push_str(&nettoyer(&node.role));
if let Some(name) = &node.name {
out.push_str(&format!(" \"{}\"", nettoyer(name)));
}
if !node.states.is_empty() {
let etats: Vec<String> = node.states.iter().map(|s| nettoyer(s)).collect();
out.push_str(&format!(" [{}]", etats.join(", ")));
}
out.push('\n');
}
let child_depth = if node.est_decoratif() {
depth
} else {
depth + 1
};
for cid in &node.child_ids {
if let Some(child) = index.get(cid.as_str()) {
render_node(
child,
index,
child_depth,
recursion + 1,
out,
visites,
compteur,
refs,
);
}
}
}
use std::sync::Mutex;
use chromiumoxide::browser::{Browser, BrowserConfig};
use futures::StreamExt;
pub enum BrowserCommand {
Launch,
Close,
Status,
Navigate(String),
Url,
Snapshot,
Screenshot,
Click {
ref_: String,
},
Type {
ref_: String,
text: String,
},
Press {
key: String,
ref_: Option<String>,
},
Scroll {
ref_: Option<String>,
dy: Option<i64>,
},
Wait {
text: Option<String>,
ms: Option<u64>,
settle: bool,
},
Eval {
js: String,
},
Select {
ref_: String,
value: String,
},
AddScript {
js: String,
},
}
fn touche_cdp(nom: &str) -> Option<(&'static str, &'static str, i64)> {
Some(match nom {
"Enter" => ("Enter", "Enter", 13),
"Tab" => ("Tab", "Tab", 9),
"Escape" => ("Escape", "Escape", 27),
"Backspace" => ("Backspace", "Backspace", 8),
"Delete" => ("Delete", "Delete", 46),
"ArrowUp" => ("ArrowUp", "ArrowUp", 38),
"ArrowDown" => ("ArrowDown", "ArrowDown", 40),
"ArrowLeft" => ("ArrowLeft", "ArrowLeft", 37),
"ArrowRight" => ("ArrowRight", "ArrowRight", 39),
"Home" => ("Home", "Home", 36),
"End" => ("End", "End", 35),
"PageUp" => ("PageUp", "PageUp", 33),
"PageDown" => ("PageDown", "PageDown", 34),
_ => return None,
})
}
#[derive(Debug)]
pub enum BrowserReply {
Ok,
Status { running: bool, url: Option<String> },
Text(String),
Shot(String),
}
struct Job {
cmd: BrowserCommand,
reply: tokio::sync::oneshot::Sender<Result<BrowserReply, String>>,
}
const EVAL_OFF_MSG: &str = "eval désactivé (browser-eval off)";
#[derive(Clone, Copy)]
pub struct BrowserOpts {
pub headless: bool,
pub eval: bool,
}
impl Default for BrowserOpts {
fn default() -> Self {
BrowserOpts {
headless: true,
eval: true,
}
}
}
pub struct BrowserEngine {
tx: Mutex<Option<tokio::sync::mpsc::Sender<Job>>>,
opts: BrowserOpts,
}
impl Default for BrowserEngine {
fn default() -> Self {
Self::new(BrowserOpts::default())
}
}
impl BrowserEngine {
pub fn new(opts: BrowserOpts) -> BrowserEngine {
BrowserEngine {
tx: Mutex::new(None),
opts,
}
}
pub fn exec(&self, cmd: BrowserCommand) -> Result<BrowserReply, String> {
let sender = self.ensure_worker();
let (rtx, rrx) = tokio::sync::oneshot::channel();
sender
.blocking_send(Job { cmd, reply: rtx })
.map_err(|_| "moteur navigateur arrêté".to_string())?;
rrx.blocking_recv()
.map_err(|_| "pas de réponse du moteur navigateur".to_string())?
}
fn ensure_worker(&self) -> tokio::sync::mpsc::Sender<Job> {
let mut g = self.tx.lock().unwrap();
if let Some(tx) = g.as_ref() {
return tx.clone();
}
let (tx, rx) = tokio::sync::mpsc::channel::<Job>(32);
let opts = self.opts;
std::thread::Builder::new()
.name("wimux-browser".into())
.spawn(move || worker(rx, opts))
.expect("thread moteur navigateur");
*g = Some(tx.clone());
tx
}
}
struct Session {
browser: Browser,
page: chromiumoxide::Page,
_handler: tokio::task::JoinHandle<()>,
refs: std::collections::HashMap<String, i64>,
}
fn worker(mut rx: tokio::sync::mpsc::Receiver<Job>, opts: BrowserOpts) {
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
while let Ok(job) = rx.try_recv() {
let _ = job.reply.send(Err(format!("runtime tokio : {e}")));
}
return;
}
};
rt.block_on(async move {
let mut sess: Option<Session> = None;
while let Some(job) = rx.recv().await {
let res = dispatch(&mut sess, job.cmd, opts).await;
let _ = job.reply.send(res);
}
});
}
async fn launch_session(headless: bool) -> Result<Session, String> {
let bin = find_browser_binary(&default_candidates())
.ok_or_else(|| "aucun navigateur Chrome/Edge trouvé sur cette machine".to_string())?;
let profile_dir = browser_profile_dir()?;
let mut builder = BrowserConfig::builder();
if !headless {
builder = builder.with_head();
}
let config = builder
.chrome_executable(bin)
.user_data_dir(&profile_dir)
.request_timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| format!("config navigateur : {e}"))?;
let (browser, mut handler) = Browser::launch(config)
.await
.map_err(|e| format!("lancement du navigateur : {e}"))?;
let handler_task = tokio::spawn(async move {
while let Some(ev) = handler.next().await {
if ev.is_err() {
break;
}
}
});
let page = browser
.new_page("about:blank")
.await
.map_err(|e| format!("ouverture de page : {e}"))?;
Ok(Session {
browser,
page,
_handler: handler_task,
refs: std::collections::HashMap::new(),
})
}
fn backend_id_for(sess: &Session, r: &str) -> Result<i64, String> {
sess.refs
.get(r)
.copied()
.ok_or_else(|| format!("ref inconnue ({r}) — refais un snapshot"))
}
fn message_erreur_js(e: chromiumoxide::error::CdpError) -> String {
use chromiumoxide::error::CdpError;
match e {
CdpError::JavascriptException(ex) => ex
.exception
.as_ref()
.and_then(|o| o.description.clone())
.unwrap_or_else(|| ex.text.clone()),
autre => autre.to_string(),
}
}
async fn scroll_into_view(page: &chromiumoxide::Page, backend: i64) -> Result<(), String> {
use chromiumoxide::cdp::browser_protocol::dom::{BackendNodeId, ScrollIntoViewIfNeededParams};
page.execute(ScrollIntoViewIfNeededParams {
node_id: None,
backend_node_id: Some(BackendNodeId::new(backend)),
object_id: None,
rect: None,
})
.await
.map_err(|e| format!("scrollIntoView : {e}"))?;
Ok(())
}
async fn element_center(page: &chromiumoxide::Page, backend: i64) -> Result<(f64, f64), String> {
use chromiumoxide::cdp::browser_protocol::dom::{BackendNodeId, GetBoxModelParams};
let resp = page
.execute(GetBoxModelParams {
node_id: None,
backend_node_id: Some(BackendNodeId::new(backend)),
object_id: None,
})
.await
.map_err(|e| format!("box model : {e}"))?;
let q = resp.result.model.content.inner();
if q.len() < 8 {
return Err("élément sans géométrie visible".into());
}
let cx = (q[0] + q[2] + q[4] + q[6]) / 4.0;
let cy = (q[1] + q[3] + q[5] + q[7]) / 4.0;
Ok((cx, cy))
}
async fn mouse_click_at(page: &chromiumoxide::Page, x: f64, y: f64) -> Result<(), String> {
use chromiumoxide::cdp::browser_protocol::input::{
DispatchMouseEventParams, DispatchMouseEventType, MouseButton,
};
let moved = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseMoved)
.x(x)
.y(y)
.build()?;
page.execute(moved)
.await
.map_err(|e| format!("clic (move) : {e}"))?;
let down = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MousePressed)
.x(x)
.y(y)
.button(MouseButton::Left)
.click_count(1)
.build()?;
page.execute(down)
.await
.map_err(|e| format!("clic (down) : {e}"))?;
let up = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseReleased)
.x(x)
.y(y)
.button(MouseButton::Left)
.click_count(1)
.build()?;
page.execute(up)
.await
.map_err(|e| format!("clic (up) : {e}"))?;
Ok(())
}
async fn mouse_wheel(page: &chromiumoxide::Page, dy: f64) -> Result<(), String> {
use chromiumoxide::cdp::browser_protocol::input::{
DispatchMouseEventParams, DispatchMouseEventType,
};
let p = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseWheel)
.x(0.0)
.y(0.0)
.delta_x(0.0)
.delta_y(dy)
.build()?;
page.execute(p)
.await
.map_err(|e| format!("molette : {e}"))?;
Ok(())
}
async fn focus_backend(page: &chromiumoxide::Page, backend: i64) -> Result<(), String> {
use chromiumoxide::cdp::browser_protocol::dom::{BackendNodeId, FocusParams};
page.execute(FocusParams {
node_id: None,
backend_node_id: Some(BackendNodeId::new(backend)),
object_id: None,
})
.await
.map_err(|e| format!("focus : {e}"))?;
Ok(())
}
async fn dispatch_key(
page: &chromiumoxide::Page,
kind: chromiumoxide::cdp::browser_protocol::input::DispatchKeyEventType,
key: &str,
code: &str,
vk: i64,
text: Option<&str>,
modifiers: Option<i64>,
) -> Result<(), String> {
use chromiumoxide::cdp::browser_protocol::input::DispatchKeyEventParams;
let mut b = DispatchKeyEventParams::builder()
.r#type(kind)
.key(key)
.code(code)
.windows_virtual_key_code(vk);
if let Some(t) = text {
b = b.text(t);
}
if let Some(m) = modifiers {
b = b.modifiers(m);
}
let p = b.build()?;
page.execute(p).await.map_err(|e| format!("touche : {e}"))?;
Ok(())
}
async fn select_all(page: &chromiumoxide::Page) -> Result<(), String> {
use chromiumoxide::cdp::browser_protocol::input::DispatchKeyEventType;
dispatch_key(
page,
DispatchKeyEventType::KeyDown,
"a",
"KeyA",
65,
None,
Some(2),
)
.await?;
dispatch_key(
page,
DispatchKeyEventType::KeyUp,
"a",
"KeyA",
65,
None,
Some(2),
)
.await?;
Ok(())
}
async fn insert_text(page: &chromiumoxide::Page, text: &str) -> Result<(), String> {
use chromiumoxide::cdp::browser_protocol::input::InsertTextParams;
page.execute(InsertTextParams::new(text.to_string()))
.await
.map_err(|e| format!("insertText : {e}"))?;
Ok(())
}
async fn dispatch(
sess: &mut Option<Session>,
cmd: BrowserCommand,
opts: BrowserOpts,
) -> Result<BrowserReply, String> {
match cmd {
BrowserCommand::Launch => {
if sess.is_none() {
*sess = Some(launch_session(opts.headless).await?);
}
Ok(BrowserReply::Ok)
}
BrowserCommand::Close => {
if let Some(s) = sess.take() {
let mut b = s.browser;
let _ = b.close().await;
let _ = b.wait().await;
}
Ok(BrowserReply::Ok)
}
BrowserCommand::Status => {
let url = match sess.as_ref() {
Some(s) => s.page.url().await.ok().flatten(),
None => None,
};
Ok(BrowserReply::Status {
running: sess.is_some(),
url,
})
}
BrowserCommand::Navigate(url) => {
if !is_allowed_url(&url) {
return Err("URL refusée : http(s) seulement".into());
}
if sess.is_none() {
*sess = Some(launch_session(opts.headless).await?);
}
sess.as_mut().unwrap().refs.clear();
let page = &sess.as_ref().unwrap().page;
tokio::time::timeout(std::time::Duration::from_secs(60), async {
page.goto(url)
.await
.map_err(|e| format!("navigation : {e}"))?;
page.wait_for_navigation()
.await
.map_err(|e| format!("attente de chargement : {e}"))?;
Ok::<(), String>(())
})
.await
.map_err(|_| "navigation : délai dépassé (60 s)".to_string())??;
let finale = sess
.as_ref()
.unwrap()
.page
.url()
.await
.ok()
.flatten()
.unwrap_or_default();
Ok(BrowserReply::Text(finale))
}
BrowserCommand::Url => {
let s = sess
.as_ref()
.ok_or_else(|| "aucun navigateur : lance-le ou navigue d'abord".to_string())?;
let u = s.page.url().await.ok().flatten().unwrap_or_default();
Ok(BrowserReply::Text(u))
}
BrowserCommand::Snapshot => {
let s = sess
.as_mut()
.ok_or_else(|| "aucun navigateur : lance-le ou navigue d'abord".to_string())?;
let nodes = snapshot_nodes(&s.page).await?;
let (texte, refs) = render_ax_tree(&nodes);
s.refs = refs.into_iter().collect();
Ok(BrowserReply::Text(texte))
}
BrowserCommand::Screenshot => {
let s = sess
.as_ref()
.ok_or_else(|| "aucun navigateur : lance-le ou navigue d'abord".to_string())?;
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
use chromiumoxide::page::ScreenshotParams;
let png = s
.page
.screenshot(
ScreenshotParams::builder()
.format(CaptureScreenshotFormat::Png)
.build(),
)
.await
.map_err(|e| format!("capture : {e}"))?;
let path = screenshot_path()?;
std::fs::write(&path, &png).map_err(|e| format!("écriture PNG : {e}"))?;
Ok(BrowserReply::Shot(path))
}
BrowserCommand::Click { ref_ } => {
let s = sess
.as_ref()
.ok_or_else(|| "aucun navigateur : lance-le ou navigue d'abord".to_string())?;
let bid = backend_id_for(s, &ref_)?;
scroll_into_view(&s.page, bid).await?;
let (x, y) = element_center(&s.page, bid).await?;
mouse_click_at(&s.page, x, y).await?;
Ok(BrowserReply::Ok)
}
BrowserCommand::Type { ref_, text } => {
let s = sess
.as_ref()
.ok_or_else(|| "aucun navigateur : lance-le ou navigue d'abord".to_string())?;
let bid = backend_id_for(s, &ref_)?;
focus_backend(&s.page, bid).await?;
select_all(&s.page).await?;
insert_text(&s.page, &text).await?;
Ok(BrowserReply::Ok)
}
BrowserCommand::Press { key, ref_ } => {
use chromiumoxide::cdp::browser_protocol::input::DispatchKeyEventType;
let s = sess
.as_ref()
.ok_or_else(|| "aucun navigateur : lance-le ou navigue d'abord".to_string())?;
if let Some(r) = &ref_ {
let bid = backend_id_for(s, r)?;
focus_backend(&s.page, bid).await?;
}
let (k, code, vk) = touche_cdp(&key).ok_or_else(|| {
format!(
"touche inconnue : {key} (gérées : Enter, Tab, Escape, Backspace, \
Delete, ArrowUp/Down/Left/Right, Home, End, PageUp, PageDown)"
)
})?;
let text = if key == "Enter" { Some("\r") } else { None };
dispatch_key(
&s.page,
DispatchKeyEventType::KeyDown,
k,
code,
vk,
text,
None,
)
.await?;
dispatch_key(
&s.page,
DispatchKeyEventType::KeyUp,
k,
code,
vk,
text,
None,
)
.await?;
Ok(BrowserReply::Ok)
}
BrowserCommand::Scroll { ref_, dy } => {
let s = sess
.as_ref()
.ok_or_else(|| "aucun navigateur : lance-le ou navigue d'abord".to_string())?;
match (ref_, dy) {
(Some(r), None) => {
let bid = backend_id_for(s, &r)?;
scroll_into_view(&s.page, bid).await?;
}
(None, Some(d)) => {
mouse_wheel(&s.page, d as f64).await?;
}
_ => return Err("scroll : fournis --ref OU --dy (exactement un)".into()),
}
Ok(BrowserReply::Ok)
}
BrowserCommand::Wait { text, ms, settle } => {
use std::time::Duration;
if let Some(t) = text {
let s = sess
.as_mut()
.ok_or_else(|| "aucun navigateur : lance-le ou navigue d'abord".to_string())?;
let nodes = tokio::time::timeout(Duration::from_secs(10), async {
loop {
let nodes = snapshot_nodes(&s.page).await?;
let (texte, _) = render_ax_tree(&nodes);
if texte.contains(t.as_str()) {
return Ok::<Vec<AxSnapshotNode>, String>(nodes);
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
})
.await
.map_err(|_| format!("wait --text : « {t} » non trouvé (timeout 10 s)"))??;
let (texte, refs) = render_ax_tree(&nodes);
s.refs = refs.into_iter().collect();
Ok(BrowserReply::Text(texte))
} else if let Some(n) = ms {
let n = n.min(60_000);
tokio::time::sleep(Duration::from_millis(n)).await;
Ok(BrowserReply::Ok)
} else if settle {
let s = sess
.as_ref()
.ok_or_else(|| "aucun navigateur : lance-le ou navigue d'abord".to_string())?;
tokio::time::timeout(Duration::from_secs(30), s.page.wait_for_navigation())
.await
.map_err(|_| "wait --settle : timeout 30 s".to_string())?
.map_err(|e| format!("wait --settle : {e}"))?;
Ok(BrowserReply::Ok)
} else {
Err("wait : fournis --text, --ms ou --settle".into())
}
}
BrowserCommand::Eval { js } => {
if !opts.eval {
return Err(EVAL_OFF_MSG.into());
}
use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
let s = sess
.as_ref()
.ok_or_else(|| "aucun navigateur : lance-le ou navigue d'abord".to_string())?;
let params = EvaluateParams::builder()
.expression(js)
.return_by_value(true)
.await_promise(true)
.build()
.map_err(|e| format!("eval (params) : {e}"))?;
let res = s
.page
.evaluate_expression(params)
.await
.map_err(|e| format!("eval : {}", message_erreur_js(e)))?;
let out = match res.value() {
Some(v) => v.to_string(),
None => "null".to_string(),
};
Ok(BrowserReply::Text(out))
}
BrowserCommand::Select { ref_, value } => {
if !opts.eval {
return Err(EVAL_OFF_MSG.into());
}
use chromiumoxide::cdp::browser_protocol::dom::{BackendNodeId, ResolveNodeParams};
use chromiumoxide::cdp::js_protocol::runtime::{
CallArgument, CallFunctionOnParams, ReleaseObjectParams,
};
let s = sess
.as_ref()
.ok_or_else(|| "aucun navigateur : lance-le ou navigue d'abord".to_string())?;
let bid = backend_id_for(s, &ref_)?;
let resolved = s
.page
.execute(ResolveNodeParams {
node_id: None,
backend_node_id: Some(BackendNodeId::new(bid)),
object_group: None,
execution_context_id: None,
})
.await
.map_err(|e| format!("select (resolveNode) : {e}"))?;
let oid = resolved
.result
.object
.object_id
.ok_or_else(|| "select : élément non résoluble".to_string())?;
let f = "function(v){ \
if (this.tagName !== 'SELECT') { return {ok:false, reason:'pas un <select>'}; } \
for (const o of this.options) { if (o.value === v) { this.value = v; \
this.dispatchEvent(new Event('change',{bubbles:true})); return {ok:true}; } } \
for (const o of this.options) { if (o.text === v) { this.value = o.value; \
this.dispatchEvent(new Event('change',{bubbles:true})); return {ok:true}; } } \
return {ok:false, reason:'aucune option ne correspond'}; }";
let call = CallFunctionOnParams::builder()
.function_declaration(f)
.object_id(oid.clone())
.argument(CallArgument {
value: Some(serde_json::Value::String(value)),
unserializable_value: None,
object_id: None,
})
.return_by_value(true)
.build()
.map_err(|e| format!("select (params) : {e}"))?;
let r = s.page.execute(call).await;
let _ = s.page.execute(ReleaseObjectParams::new(oid)).await;
let r = r.map_err(|e| format!("select (callFunctionOn) : {e}"))?;
if let Some(ex) = &r.result.exception_details {
let msg = ex
.exception
.as_ref()
.and_then(|o| o.description.clone())
.unwrap_or_else(|| ex.text.clone());
return Err(format!("select : {msg}"));
}
let val = r
.result
.result
.value
.ok_or_else(|| "select : réponse vide".to_string())?;
if val.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) {
Ok(BrowserReply::Ok)
} else {
let reason = val
.get("reason")
.and_then(|s| s.as_str())
.unwrap_or("échec")
.to_string();
Err(format!("select : {reason}"))
}
}
BrowserCommand::AddScript { js } => {
if !opts.eval {
return Err(EVAL_OFF_MSG.into());
}
let s = sess
.as_ref()
.ok_or_else(|| "aucun navigateur : lance-le ou navigue d'abord".to_string())?;
let id = s
.page
.evaluate_on_new_document(js)
.await
.map_err(|e| format!("addscript : {e}"))?;
Ok(BrowserReply::Text(id.inner().clone()))
}
}
}
fn map_ax_node(n: &chromiumoxide::cdp::browser_protocol::accessibility::AxNode) -> AxSnapshotNode {
let role = n
.role
.as_ref()
.and_then(|v| v.value.as_ref())
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let name = n
.name
.as_ref()
.and_then(|v| v.value.as_ref())
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let states: Vec<String> = n
.properties
.iter()
.flatten()
.filter_map(|p| {
let est_vrai = p
.value
.value
.as_ref()
.and_then(|v| v.as_bool())
.unwrap_or(false);
est_vrai.then(|| p.name.as_ref().to_ascii_lowercase())
})
.collect();
AxSnapshotNode {
node_id: n.node_id.inner().clone(),
role,
name,
states,
child_ids: n
.child_ids
.iter()
.flatten()
.map(|c| c.inner().clone())
.collect(),
backend_node_id: n.backend_dom_node_id.as_ref().map(|b| *b.inner()),
}
}
async fn snapshot_nodes(page: &chromiumoxide::Page) -> Result<Vec<AxSnapshotNode>, String> {
use chromiumoxide::cdp::browser_protocol::accessibility::GetFullAxTreeParams;
let resp = page
.execute(GetFullAxTreeParams::default())
.await
.map_err(|e| format!("arbre d'accessibilité : {e}"))?;
Ok(resp.result.nodes.iter().map(map_ax_node).collect())
}
fn browser_profile_dir() -> Result<PathBuf, String> {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let base =
std::env::var_os("LOCALAPPDATA").ok_or_else(|| "%LOCALAPPDATA% introuvable".to_string())?;
let i = N.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let dir = PathBuf::from(base)
.join("wimux")
.join("browser-profile")
.join(format!("{pid}-{i}"));
std::fs::create_dir_all(&dir)
.map_err(|e| format!("création dossier profil navigateur : {e}"))?;
Ok(dir)
}
fn screenshot_path() -> Result<String, String> {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let base =
std::env::var_os("LOCALAPPDATA").ok_or_else(|| "%LOCALAPPDATA% introuvable".to_string())?;
let dir = std::path::PathBuf::from(base)
.join("wimux")
.join("screenshots");
std::fs::create_dir_all(&dir).map_err(|e| format!("création dossier captures : {e}"))?;
let i = N.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
Ok(dir
.join(format!("shot-{pid}-{i}.png"))
.to_string_lossy()
.into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn navigateur_dispo() -> bool {
find_browser_binary(&default_candidates()).is_some()
}
fn servir_page_locale(html: &'static str) -> (String, std::thread::JoinHandle<()>) {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let handle = std::thread::spawn(move || {
for _ in 0..8 {
let Ok((mut stream, _)) = listener.accept() else {
break;
};
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
);
let _ = stream.write_all(resp.as_bytes());
}
});
(format!("http://127.0.0.1:{port}/"), handle)
}
fn ref_pour(snapshot: &str, besoin: &str) -> Option<String> {
let ligne = snapshot.lines().find(|l| l.contains(besoin))?;
let deb = ligne.find("[ref=")? + 5;
let fin = ligne[deb..].find(']')? + deb;
Some(ligne[deb..fin].to_string())
}
#[test]
#[ignore = "lance un vrai navigateur (Edge) : instable/lent en CI ; en local `cargo test -- --ignored`"]
fn click_declenche_le_gestionnaire_de_la_page() {
if !navigateur_dispo() {
eprintln!("aucun navigateur : test click ignoré");
return;
}
let (url, _srv) = servir_page_locale(
"<!doctype html><title>T</title>\
<button onclick=\"document.getElementById('r').textContent='clické'\">Go</button>\
<p id=r>vide</p>",
);
let engine = BrowserEngine::new(BrowserOpts::default());
engine.exec(BrowserCommand::Navigate(url)).unwrap();
let snap = match engine.exec(BrowserCommand::Snapshot).unwrap() {
BrowserReply::Text(t) => t,
_ => panic!("Text"),
};
let r = ref_pour(&snap, "Go").expect("ref du bouton Go");
assert!(matches!(
engine.exec(BrowserCommand::Click { ref_: r }).unwrap(),
BrowserReply::Ok
));
match engine.exec(BrowserCommand::Snapshot).unwrap() {
BrowserReply::Text(t) => assert!(t.contains("clické"), "après clic : {t}"),
_ => panic!("Text"),
}
let _ = engine.exec(BrowserCommand::Close);
}
#[test]
#[ignore = "lance un vrai navigateur (Edge) : instable/lent en CI ; en local `cargo test -- --ignored`"]
fn navigate_refuse_les_schemas_non_http() {
if !navigateur_dispo() {
eprintln!("aucun navigateur : test navigate_refuse ignoré");
return;
}
let engine = BrowserEngine::new(BrowserOpts::default());
let err = engine
.exec(BrowserCommand::Navigate("file:///C:/x".into()))
.unwrap_err();
assert!(err.contains("http"), "message de refus attendu : {err}");
let _ = engine.exec(BrowserCommand::Close);
}
#[test]
#[ignore = "lance un vrai navigateur (Edge) : instable/lent en CI ; en local `cargo test -- --ignored`"]
fn navigate_puis_url_reflete_la_page() {
if !navigateur_dispo() {
eprintln!("aucun navigateur : test navigate_puis_url ignoré");
return;
}
let (url, _srv) = servir_page_locale("<!doctype html><title>T</title><h1>Bonjour</h1>");
let engine = BrowserEngine::new(BrowserOpts::default());
match engine.exec(BrowserCommand::Navigate(url.clone())).unwrap() {
BrowserReply::Text(finale) => assert!(finale.starts_with("http://127.0.0.1:")),
_ => panic!("Text attendu"),
}
match engine.exec(BrowserCommand::Url).unwrap() {
BrowserReply::Text(u) => assert!(u.starts_with("http://127.0.0.1:"), "url : {u}"),
_ => panic!("Text attendu"),
}
let _ = engine.exec(BrowserCommand::Close);
}
#[test]
#[ignore = "lance un vrai navigateur (Edge) : instable/lent en CI ; en local `cargo test -- --ignored`"]
fn launch_status_close_cycle() {
if !navigateur_dispo() {
eprintln!("aucun Chrome/Edge : test launch_status_close ignoré");
return;
}
let engine = BrowserEngine::new(BrowserOpts::default());
match engine.exec(BrowserCommand::Status).unwrap() {
BrowserReply::Status { running, .. } => assert!(!running),
_ => panic!("Status attendu"),
}
assert!(matches!(
engine.exec(BrowserCommand::Launch).unwrap(),
BrowserReply::Ok
));
match engine.exec(BrowserCommand::Status).unwrap() {
BrowserReply::Status { running, .. } => assert!(running),
_ => panic!("Status attendu"),
}
assert!(matches!(
engine.exec(BrowserCommand::Close).unwrap(),
BrowserReply::Ok
));
match engine.exec(BrowserCommand::Status).unwrap() {
BrowserReply::Status { running, .. } => assert!(!running),
_ => panic!("Status attendu"),
}
}
#[test]
fn find_binary_renvoie_le_premier_existant() {
let moi = std::env::current_exe().unwrap();
let bidon = PathBuf::from("Z:/inexistant/xyz.exe");
assert_eq!(
find_browser_binary(&[bidon.clone(), moi.clone()]),
Some(moi)
);
assert_eq!(find_browser_binary(&[bidon]), None);
}
#[test]
fn url_autorisee_http_https_seulement() {
assert!(is_allowed_url("http://localhost:8899/"));
assert!(is_allowed_url("https://example.com/x"));
assert!(is_allowed_url("HTTPS://Example.com")); assert!(!is_allowed_url("file:///C:/x"));
assert!(!is_allowed_url("javascript:alert(1)"));
assert!(!is_allowed_url("data:text/html,x"));
assert!(!is_allowed_url("about:blank"));
assert!(!is_allowed_url("localhost:8899")); }
#[test]
fn render_numerote_les_noeuds_affiches_avec_backend_id() {
let nodes = vec![
AxSnapshotNode {
node_id: "1".into(),
role: "none".into(),
name: None,
states: vec![],
child_ids: vec!["2".into(), "3".into()],
backend_node_id: None,
},
AxSnapshotNode {
node_id: "2".into(),
role: "button".into(),
name: Some("Continuer".into()),
states: vec!["focusable".into()],
child_ids: vec![],
backend_node_id: Some(100),
},
AxSnapshotNode {
node_id: "3".into(),
role: "link".into(),
name: Some("Aide".into()),
states: vec![],
child_ids: vec![],
backend_node_id: Some(200),
},
];
let (texte, refs) = render_ax_tree(&nodes);
assert!(
texte.contains("[ref=e1] button \"Continuer\" [focusable]"),
"texte : {texte}"
);
assert!(texte.contains("[ref=e2] link \"Aide\""), "texte : {texte}");
assert_eq!(refs, vec![("e1".to_string(), 100), ("e2".to_string(), 200)]);
}
#[test]
fn render_noeud_affiche_sans_backend_id_na_pas_de_ref() {
let nodes = vec![AxSnapshotNode {
node_id: "1".into(),
role: "heading".into(),
name: Some("Titre".into()),
states: vec![],
child_ids: vec![],
backend_node_id: None,
}];
let (texte, refs) = render_ax_tree(&nodes);
assert_eq!(texte, "heading \"Titre\"");
assert!(refs.is_empty());
}
#[test]
fn render_ax_tree_indente_role_nom_etats_et_elague() {
let nodes = vec![
AxSnapshotNode {
node_id: "1".into(),
role: "RootWebArea".into(),
name: Some("Page de test".into()),
states: vec![],
child_ids: vec!["2".into(), "3".into()],
backend_node_id: None,
},
AxSnapshotNode {
node_id: "2".into(),
role: "button".into(),
name: Some("Continuer".into()),
states: vec!["focusable".into()],
child_ids: vec![],
backend_node_id: None,
},
AxSnapshotNode {
node_id: "3".into(),
role: "none".into(),
name: None,
states: vec![],
child_ids: vec![],
backend_node_id: None,
},
];
let (out, _) = render_ax_tree(&nodes);
assert!(
out.contains("RootWebArea \"Page de test\""),
"racine : {out}"
);
assert!(
out.contains(" button \"Continuer\" [focusable]"),
"bouton indenté : {out}"
);
assert!(
!out.contains("none"),
"le nœud décoratif doit être élagué : {out}"
);
}
#[test]
fn render_ax_tree_vide_donne_chaine_vide() {
let (out, refs) = render_ax_tree(&[]);
assert_eq!(out, "");
assert!(refs.is_empty());
}
#[test]
fn render_ax_tree_promeut_les_enfants_dun_noeud_decoratif_avec_enfants() {
let nodes = vec![
AxSnapshotNode {
node_id: "1".into(),
role: "RootWebArea".into(),
name: Some("Page".into()),
states: vec![],
child_ids: vec!["2".into()],
backend_node_id: None,
},
AxSnapshotNode {
node_id: "2".into(),
role: "none".into(),
name: None,
states: vec![],
child_ids: vec!["3".into(), "4".into()],
backend_node_id: None,
},
AxSnapshotNode {
node_id: "3".into(),
role: "button".into(),
name: Some("A".into()),
states: vec![],
child_ids: vec![],
backend_node_id: None,
},
AxSnapshotNode {
node_id: "4".into(),
role: "link".into(),
name: Some("B".into()),
states: vec![],
child_ids: vec![],
backend_node_id: None,
},
];
let (out, _) = render_ax_tree(&nodes);
assert!(!out.contains("none"), "le wrapper élagué : {out}");
assert!(
out.contains("\n button \"A\"\n") || out.ends_with("\n button \"A\""),
"button indenté d'un seul cran : {out:?}"
);
assert!(
out.contains("\n link \"B\"\n") || out.ends_with("\n link \"B\""),
"link indenté d'un seul cran : {out:?}"
);
assert!(
!out.contains(" button") && !out.contains(" link"),
"pas de double indentation : {out:?}"
);
}
#[test]
#[ignore = "lance un vrai navigateur (Edge) : instable/lent en CI ; en local `cargo test -- --ignored`"]
fn snapshot_et_screenshot_sur_page_locale() {
if !navigateur_dispo() {
eprintln!("aucun navigateur : test snapshot/screenshot ignoré");
return;
}
let (url, _srv) =
servir_page_locale("<!doctype html><title>T</title><button>Continuer</button>");
let engine = BrowserEngine::new(BrowserOpts::default());
engine.exec(BrowserCommand::Navigate(url)).unwrap();
match engine.exec(BrowserCommand::Snapshot).unwrap() {
BrowserReply::Text(tree) => {
assert!(
tree.contains("button"),
"le bouton doit apparaître : {tree}"
);
assert!(
tree.contains("Continuer"),
"son nom doit apparaître : {tree}"
);
}
_ => panic!("Text attendu"),
}
match engine.exec(BrowserCommand::Screenshot).unwrap() {
BrowserReply::Shot(path) => {
let p = std::path::Path::new(&path);
assert!(p.exists(), "le PNG doit exister : {path}");
assert!(
std::fs::metadata(p).unwrap().len() > 0,
"le PNG ne doit pas être vide"
);
let _ = std::fs::remove_file(p);
}
_ => panic!("Shot attendu"),
}
let _ = engine.exec(BrowserCommand::Close);
}
#[test]
#[ignore = "lance un vrai navigateur (Edge) : instable/lent en CI ; en local `cargo test -- --ignored`"]
fn snapshot_expose_des_refs_pour_les_elements() {
if !navigateur_dispo() {
eprintln!("aucun navigateur : test snapshot_refs ignoré");
return;
}
let (url, _srv) =
servir_page_locale("<!doctype html><title>T</title><button>Continuer</button>");
let engine = BrowserEngine::new(BrowserOpts::default());
engine.exec(BrowserCommand::Navigate(url)).unwrap();
match engine.exec(BrowserCommand::Snapshot).unwrap() {
BrowserReply::Text(t) => {
assert!(t.contains("[ref=e"), "snapshot sans ref : {t}");
assert!(t.contains("button \"Continuer\""), "snapshot : {t}");
}
_ => panic!("Text attendu"),
}
let _ = engine.exec(BrowserCommand::Close);
}
#[test]
fn render_ax_tree_cycle_termine_sans_deborder_la_pile() {
let nodes = vec![
AxSnapshotNode {
node_id: "a".into(),
role: "generic".into(),
name: Some("A".into()),
states: vec![],
child_ids: vec!["b".into()],
backend_node_id: None,
},
AxSnapshotNode {
node_id: "b".into(),
role: "generic".into(),
name: Some("B".into()),
states: vec![],
child_ids: vec!["a".into()],
backend_node_id: None,
},
];
let (out, _) = render_ax_tree(&nodes);
assert_eq!(out.matches("generic \"A\"").count(), 1, "sortie : {out}");
assert_eq!(out.matches("generic \"B\"").count(), 1, "sortie : {out}");
}
#[test]
fn render_arbre_tres_profond_ne_deborde_pas() {
let n = 5000usize;
let nodes: Vec<AxSnapshotNode> = (0..n)
.map(|k| AxSnapshotNode {
node_id: k.to_string(),
role: "generic".into(),
name: Some(format!("n{k}")),
states: vec![],
child_ids: if k + 1 < n {
vec![(k + 1).to_string()]
} else {
vec![]
},
backend_node_id: None,
})
.collect();
let (out, _) = render_ax_tree(&nodes);
let lignes = out.lines().count();
assert!(
lignes <= PROFONDEUR_MAX,
"profondeur bornée : {lignes} lignes (max {PROFONDEUR_MAX})"
);
assert!(out.contains("generic \"n0\""), "racine rendue : {out:?}");
}
#[test]
fn render_chaine_decorative_profonde_ne_deborde_pas() {
let n = 5000usize;
let nodes: Vec<AxSnapshotNode> = (0..n)
.map(|k| AxSnapshotNode {
node_id: k.to_string(),
role: "none".into(),
name: None,
states: vec![],
child_ids: if k + 1 < n {
vec![(k + 1).to_string()]
} else {
vec![]
},
backend_node_id: None,
})
.collect();
let (out, _) = render_ax_tree(&nodes);
assert!(
out.is_empty(),
"chaîne décorative -> rien à rendre : {out:?}"
);
}
#[test]
fn render_neutralise_caracteres_de_controle() {
let nodes = vec![AxSnapshotNode {
node_id: "1".into(),
role: "RootWebArea".into(),
name: Some("\x1b]0;evil\x07\nbutton \"Delete all\"".into()),
states: vec![],
child_ids: vec![],
backend_node_id: None,
}];
let (out, _) = render_ax_tree(&nodes);
assert!(!out.contains('\x1b'), "pas d'ESC dans la sortie : {out:?}");
assert!(!out.contains('\x07'), "pas de BEL dans la sortie : {out:?}");
assert_eq!(out.lines().count(), 1, "une seule ligne : {out:?}");
}
#[test]
#[ignore = "lance un vrai navigateur (Edge) : instable/lent en CI ; en local `cargo test -- --ignored`"]
fn type_ecrit_dans_un_champ() {
if !navigateur_dispo() {
eprintln!("aucun navigateur : test type ignoré");
return;
}
let (url, _srv) = servir_page_locale(
"<!doctype html><title>T</title>\
<input aria-label=Nom oninput=\"document.getElementById('m').textContent=this.value\">\
<p id=m>vide</p>",
);
let engine = BrowserEngine::new(BrowserOpts::default());
engine.exec(BrowserCommand::Navigate(url)).unwrap();
let snap = match engine.exec(BrowserCommand::Snapshot).unwrap() {
BrowserReply::Text(t) => t,
_ => panic!("Text"),
};
let r = ref_pour(&snap, "Nom").expect("ref du champ");
assert!(matches!(
engine
.exec(BrowserCommand::Type {
ref_: r,
text: "Fabrice".into()
})
.unwrap(),
BrowserReply::Ok
));
match engine.exec(BrowserCommand::Snapshot).unwrap() {
BrowserReply::Text(t) => assert!(t.contains("Fabrice"), "après type : {t}"),
_ => panic!("Text"),
}
let _ = engine.exec(BrowserCommand::Close);
}
#[test]
fn touche_cdp_connait_les_touches_usuelles() {
assert_eq!(touche_cdp("Enter"), Some(("Enter", "Enter", 13)));
assert_eq!(
touche_cdp("ArrowDown"),
Some(("ArrowDown", "ArrowDown", 40))
);
assert_eq!(touche_cdp("PageUp"), Some(("PageUp", "PageUp", 33)));
assert_eq!(touche_cdp("Grokk"), None);
}
#[test]
#[ignore = "lance un vrai navigateur (Edge) : instable/lent en CI ; en local `cargo test -- --ignored`"]
fn press_envoie_la_touche_a_lelement_focalise() {
if !navigateur_dispo() {
eprintln!("aucun navigateur : test press ignoré");
return;
}
let (url, _srv) = servir_page_locale(
"<!doctype html><title>T</title>\
<input aria-label=Q onkeydown=\"location.hash=event.key\">",
);
let engine = BrowserEngine::new(BrowserOpts::default());
engine.exec(BrowserCommand::Navigate(url)).unwrap();
let snap = match engine.exec(BrowserCommand::Snapshot).unwrap() {
BrowserReply::Text(t) => t,
_ => panic!("Text"),
};
let r = ref_pour(&snap, "Q").expect("ref du champ");
engine
.exec(BrowserCommand::Press {
key: "ArrowDown".into(),
ref_: Some(r),
})
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
match engine.exec(BrowserCommand::Url).unwrap() {
BrowserReply::Text(u) => assert!(u.contains("ArrowDown"), "url après press : {u}"),
_ => panic!("Text attendu"),
}
let _ = engine.exec(BrowserCommand::Close);
}
#[test]
#[ignore = "lance un vrai navigateur (Edge) : instable/lent en CI ; en local `cargo test -- --ignored`"]
fn scroll_vers_une_ref_reussit() {
if !navigateur_dispo() {
eprintln!("aucun navigateur : test scroll ignoré");
return;
}
let (url, _srv) = servir_page_locale(
"<!doctype html><title>T</title><div style=height:3000px></div>\
<button>EnBas</button>",
);
let engine = BrowserEngine::new(BrowserOpts::default());
engine.exec(BrowserCommand::Navigate(url)).unwrap();
let snap = match engine.exec(BrowserCommand::Snapshot).unwrap() {
BrowserReply::Text(t) => t,
_ => panic!("Text"),
};
let r = ref_pour(&snap, "EnBas").expect("ref du bouton bas");
assert!(matches!(
engine
.exec(BrowserCommand::Scroll {
ref_: Some(r),
dy: None
})
.unwrap(),
BrowserReply::Ok
));
assert!(matches!(
engine
.exec(BrowserCommand::Scroll {
ref_: None,
dy: Some(-500)
})
.unwrap(),
BrowserReply::Ok
));
let _ = engine.exec(BrowserCommand::Close);
}
#[test]
#[ignore = "lance un vrai navigateur (Edge) : instable/lent en CI ; en local `cargo test -- --ignored`"]
fn wait_text_attend_un_contenu_differe() {
if !navigateur_dispo() {
eprintln!("aucun navigateur : test wait ignoré");
return;
}
let (url, _srv) = servir_page_locale(
"<!doctype html><title>T</title><p id=r>patiente</p>\
<script>setTimeout(()=>{document.getElementById('r').textContent='PRÊT'},300)</script>",
);
let engine = BrowserEngine::new(BrowserOpts::default());
engine.exec(BrowserCommand::Navigate(url)).unwrap();
match engine
.exec(BrowserCommand::Wait {
text: Some("PRÊT".into()),
ms: None,
settle: false,
})
.unwrap()
{
BrowserReply::Text(t) => assert!(t.contains("PRÊT"), "wait a renvoyé : {t}"),
_ => panic!("Text attendu"),
}
let err = engine
.exec(BrowserCommand::Wait {
text: Some("JAMAIS".into()),
ms: None,
settle: false,
})
.unwrap_err();
assert!(
err.contains("timeout") || err.contains("non trouvé"),
"err : {err}"
);
let _ = engine.exec(BrowserCommand::Close);
}
#[test]
#[ignore = "lance un vrai navigateur (Edge) : instable/lent en CI ; en local `cargo test -- --ignored`"]
fn click_sous_la_ligne_de_flottaison() {
if !navigateur_dispo() {
eprintln!("aucun navigateur : test click sous la ligne ignoré");
return;
}
let (url, _srv) = servir_page_locale(
"<!doctype html><title>T</title><div style=height:2000px></div>\
<button onclick=\"document.getElementById('r').textContent='clické'\">Bas</button>\
<p id=r>vide</p>",
);
let engine = BrowserEngine::new(BrowserOpts::default());
engine.exec(BrowserCommand::Navigate(url)).unwrap();
let snap = match engine.exec(BrowserCommand::Snapshot).unwrap() {
BrowserReply::Text(t) => t,
_ => panic!("Text"),
};
let r = ref_pour(&snap, "Bas").expect("ref du bouton bas");
engine.exec(BrowserCommand::Click { ref_: r }).unwrap();
match engine.exec(BrowserCommand::Snapshot).unwrap() {
BrowserReply::Text(t) => assert!(t.contains("clické"), "après clic bas : {t}"),
_ => panic!("Text"),
}
let _ = engine.exec(BrowserCommand::Close);
}
#[test]
#[ignore = "lance un vrai navigateur (Edge) : instable/lent en CI ; en local `cargo test -- --ignored`"]
fn eval_calcule_lit_le_dom_et_attend_les_promesses() {
if !navigateur_dispo() {
eprintln!("aucun navigateur : test eval ignoré");
return;
}
let (url, _srv) =
servir_page_locale("<!doctype html><title>TitreX</title><h1>Bonjour</h1>");
let engine = BrowserEngine::new(BrowserOpts::default());
engine.exec(BrowserCommand::Navigate(url)).unwrap();
match engine
.exec(BrowserCommand::Eval { js: "1 + 2".into() })
.unwrap()
{
BrowserReply::Text(t) => assert_eq!(t, "3"),
_ => panic!("Text"),
}
match engine
.exec(BrowserCommand::Eval {
js: "document.title".into(),
})
.unwrap()
{
BrowserReply::Text(t) => assert_eq!(t, "\"TitreX\""),
_ => panic!("Text"),
}
match engine
.exec(BrowserCommand::Eval {
js: "Promise.resolve(42)".into(),
})
.unwrap()
{
BrowserReply::Text(t) => assert_eq!(t, "42"),
_ => panic!("Text"),
}
let err = engine
.exec(BrowserCommand::Eval {
js: "definitivement.pas.defini".into(),
})
.unwrap_err();
assert!(
err.contains("ReferenceError"),
"message JS lisible attendu : {err}"
);
assert!(
!err.contains("ExceptionDetails"),
"dump Debug non voulu : {err}"
);
let _ = engine.exec(BrowserCommand::Close);
}
#[test]
#[ignore = "lance un vrai navigateur (Edge) : instable/lent en CI ; en local `cargo test -- --ignored`"]
fn select_choisit_par_valeur_puis_par_texte() {
if !navigateur_dispo() {
eprintln!("aucun navigateur : test select ignoré");
return;
}
let (url, _srv) = servir_page_locale(
"<!doctype html><title>T</title>\
<select aria-label=Choix>\
<option value=a>Alpha</option><option value=b>Bravo</option><option value=c>Charlie</option>\
</select>",
);
let engine = BrowserEngine::new(BrowserOpts::default());
engine.exec(BrowserCommand::Navigate(url)).unwrap();
let snap = match engine.exec(BrowserCommand::Snapshot).unwrap() {
BrowserReply::Text(t) => t,
_ => panic!("Text"),
};
let r = ref_pour(&snap, "Choix").expect("ref du select");
engine
.exec(BrowserCommand::Select {
ref_: r.clone(),
value: "b".into(),
})
.unwrap();
match engine
.exec(BrowserCommand::Eval {
js: "document.querySelector('select').value".into(),
})
.unwrap()
{
BrowserReply::Text(t) => assert_eq!(t, "\"b\""),
_ => panic!("Text"),
}
engine
.exec(BrowserCommand::Select {
ref_: r.clone(),
value: "Charlie".into(),
})
.unwrap();
match engine
.exec(BrowserCommand::Eval {
js: "document.querySelector('select').value".into(),
})
.unwrap()
{
BrowserReply::Text(t) => assert_eq!(t, "\"c\""),
_ => panic!("Text"),
}
let err = engine
.exec(BrowserCommand::Select {
ref_: r,
value: "Zzz".into(),
})
.unwrap_err();
assert!(err.contains("select"), "err : {err}");
let _ = engine.exec(BrowserCommand::Close);
}
#[test]
#[ignore = "lance un vrai navigateur (Edge) : instable/lent en CI ; en local `cargo test -- --ignored`"]
fn addscript_sexecute_au_chargement_suivant() {
if !navigateur_dispo() {
eprintln!("aucun navigateur : test addscript ignoré");
return;
}
let (url, _srv) = servir_page_locale("<!doctype html><title>T</title><h1>x</h1>");
let engine = BrowserEngine::new(BrowserOpts::default());
engine.exec(BrowserCommand::Navigate(url.clone())).unwrap();
engine
.exec(BrowserCommand::AddScript {
js: "window.__wimux = 7".into(),
})
.unwrap();
engine.exec(BrowserCommand::Navigate(url)).unwrap();
match engine
.exec(BrowserCommand::Eval {
js: "window.__wimux".into(),
})
.unwrap()
{
BrowserReply::Text(t) => assert_eq!(t, "7"),
_ => panic!("Text"),
}
let _ = engine.exec(BrowserCommand::Close);
}
#[test]
#[ignore = "lance un vrai navigateur (Edge) : instable/lent en CI ; en local `cargo test -- --ignored`"]
fn valve_browser_eval_off_refuse_le_scripting() {
if !navigateur_dispo() {
eprintln!("aucun navigateur : test valve eval ignoré");
return;
}
let (url, _srv) = servir_page_locale("<!doctype html><title>T</title><button>B</button>");
let engine = BrowserEngine::new(BrowserOpts {
headless: true,
eval: false,
});
engine.exec(BrowserCommand::Navigate(url)).unwrap();
let e = engine
.exec(BrowserCommand::Eval { js: "1 + 1".into() })
.unwrap_err();
assert_eq!(e, EVAL_OFF_MSG, "eval : {e}");
let e = engine
.exec(BrowserCommand::Select {
ref_: "e1".into(),
value: "x".into(),
})
.unwrap_err();
assert_eq!(e, EVAL_OFF_MSG, "select : {e}");
let e = engine
.exec(BrowserCommand::AddScript { js: "1".into() })
.unwrap_err();
assert_eq!(e, EVAL_OFF_MSG, "addscript : {e}");
match engine.exec(BrowserCommand::Snapshot).unwrap() {
BrowserReply::Text(t) => assert!(t.contains("button"), "snapshot : {t}"),
_ => panic!("Text"),
}
let _ = engine.exec(BrowserCommand::Close);
}
}