use serde::Deserialize;
use serde_json::{Value, json};
use crate::assertions::VerifyBuilder;
use crate::discovery::{scan_discovery_dirs_for_port, scan_discovery_dirs_for_token};
use crate::error::TestError;
use crate::visual::{VisualDiff, VisualOptions};
#[derive(Debug, Clone, Deserialize)]
pub struct PluginInfo {
pub version: String,
pub uptime_secs: f64,
pub tool_count: usize,
pub tool_invocations: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MemoryStats {
pub working_set_bytes: u64,
pub peak_working_set_bytes: Option<u64>,
}
pub struct WaitForBuilder<'a> {
client: &'a mut VictauriClient,
condition: String,
value: Option<String>,
timeout_ms: u64,
poll_ms: u64,
}
impl<'a> WaitForBuilder<'a> {
#[must_use]
pub fn value(mut self, v: &str) -> Self {
self.value = Some(v.to_string());
self
}
#[must_use]
pub fn timeout_ms(mut self, ms: u64) -> Self {
self.timeout_ms = ms;
self
}
#[must_use]
pub fn poll_ms(mut self, ms: u64) -> Self {
self.poll_ms = ms;
self
}
pub async fn run(self) -> Result<Value, TestError> {
self.client
.wait_for(
&self.condition,
self.value.as_deref(),
Some(self.timeout_ms),
Some(self.poll_ms),
)
.await
}
}
pub struct VictauriClient {
http: reqwest::Client,
base_url: String,
host: String,
port: u16,
session_id: String,
next_id: u64,
auth_token: Option<String>,
}
impl VictauriClient {
pub async fn connect(port: u16) -> Result<Self, TestError> {
Self::connect_with_token(port, None).await
}
pub async fn connect_with_token(port: u16, token: Option<&str>) -> Result<Self, TestError> {
let host = "127.0.0.1";
let base_url = format!("http://{host}:{port}");
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| TestError::Connection {
host: host.to_string(),
port,
reason: e.to_string(),
})?;
let init_body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "victauri-test", "version": env!("CARGO_PKG_VERSION")}
}
});
let mut init_resp = None;
for attempt in 0..4 {
let mut req = http
.post(format!("{base_url}/mcp"))
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.json(&init_body);
if let Some(t) = token {
req = req.header("Authorization", format!("Bearer {t}"));
}
let resp = req.send().await.map_err(|e| TestError::Connection {
host: host.to_string(),
port,
reason: e.to_string(),
})?;
if resp.status() == 429 && attempt < 3 {
let delay = std::time::Duration::from_millis(100 * (1 << attempt));
tokio::time::sleep(delay).await;
continue;
}
init_resp = Some(resp);
break;
}
let init_resp = init_resp.ok_or_else(|| TestError::Connection {
host: host.to_string(),
port,
reason: "initialize failed after retries".into(),
})?;
if !init_resp.status().is_success() {
return Err(TestError::Connection {
host: host.to_string(),
port,
reason: format!("initialize returned {}", init_resp.status()),
});
}
let session_id = init_resp
.headers()
.get("mcp-session-id")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| TestError::Connection {
host: host.to_string(),
port,
reason: "no mcp-session-id header".into(),
})?
.to_string();
let mut notify_req = http
.post(format!("{base_url}/mcp"))
.header("Content-Type", "application/json")
.header("mcp-session-id", &session_id)
.json(&json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
}));
if let Some(t) = token {
notify_req = notify_req.header("Authorization", format!("Bearer {t}"));
}
notify_req.send().await?;
Ok(Self {
http,
base_url,
host: host.to_string(),
port,
session_id,
next_id: 10,
auth_token: token.map(String::from),
})
}
pub async fn discover() -> Result<Self, TestError> {
let port = Self::discover_port();
let token = Self::discover_token();
Self::connect_with_token(port, token.as_deref()).await
}
fn discover_port() -> u16 {
if let Ok(p) = std::env::var("VICTAURI_PORT")
&& let Ok(port) = p.parse::<u16>()
{
return port;
}
if let Some(port) = scan_discovery_dirs_for_port() {
return port;
}
7373
}
fn discover_token() -> Option<String> {
if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN") {
return Some(token);
}
if let Some(token) = scan_discovery_dirs_for_token() {
return Some(token);
}
None
}
#[must_use]
pub async fn is_alive(&self) -> bool {
self.http
.get(format!("{}/health", self.base_url))
.send()
.await
.is_ok_and(|r| r.status().is_success())
}
pub async fn reconnect(&self, max_wait: std::time::Duration) -> Result<Self, TestError> {
let start = std::time::Instant::now();
loop {
if self.is_alive().await {
return Self::connect_with_token(self.port, self.auth_token.as_deref()).await;
}
if start.elapsed() > max_wait {
return Err(TestError::Connection {
host: self.host.clone(),
port: self.port,
reason: format!("server did not recover within {}s", max_wait.as_secs()),
});
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
}
pub async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, TestError> {
let id = self.next_id;
self.next_id += 1;
let call_body = json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": {
"name": name,
"arguments": arguments
}
});
let mut resp = None;
for attempt in 0..4 {
let mut req = self
.http
.post(format!("{}/mcp", self.base_url))
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("mcp-session-id", &self.session_id)
.json(&call_body);
if let Some(ref t) = self.auth_token {
req = req.header("Authorization", format!("Bearer {t}"));
}
let r = req.send().await?;
if r.status() == 429 && attempt < 3 {
let delay = std::time::Duration::from_millis(100 * (1 << attempt));
tokio::time::sleep(delay).await;
continue;
}
resp = Some(r);
break;
}
let resp = resp.ok_or_else(|| TestError::Connection {
host: self.host.clone(),
port: self.port,
reason: "tool call failed after retries".into(),
})?;
let body = Self::parse_response(resp, &self.host, self.port).await?;
if let Some(error) = body.get("error") {
return Err(TestError::Mcp {
code: error["code"].as_i64().unwrap_or(-1),
message: error["message"].as_str().map_or_else(
|| {
format!(
"unknown error (raw: {})",
serde_json::to_string(error).unwrap_or_else(|_| "<unparseable>".into())
)
},
String::from,
),
});
}
let content = &body["result"]["content"];
if let Some(arr) = content.as_array()
&& let Some(first) = arr.first()
&& let Some(text) = first["text"].as_str()
{
if let Ok(parsed) = serde_json::from_str::<Value>(text) {
return Ok(parsed);
}
return Ok(Value::String(text.to_string()));
}
Ok(body)
}
async fn parse_response(
resp: reqwest::Response,
host: &str,
port: u16,
) -> Result<Value, TestError> {
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let text = resp.text().await?;
if content_type.contains("text/event-stream") {
for line in text.lines() {
let data = line
.strip_prefix("data: ")
.or_else(|| line.strip_prefix("data:"));
let Some(data) = data else { continue };
let trimmed = data.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
return Ok(parsed);
}
}
Err(TestError::Connection {
host: host.to_string(),
port,
reason: "SSE stream contained no JSON-RPC data".into(),
})
} else {
serde_json::from_str(&text).map_err(|e| TestError::Connection {
host: host.to_string(),
port,
reason: format!(
"JSON parse error: {e}, body: {}",
&text[..200.min(text.len())]
),
})
}
}
pub async fn eval_js(&mut self, code: &str) -> Result<Value, TestError> {
self.call_tool("eval_js", json!({"code": code})).await
}
pub async fn dom_snapshot(&mut self) -> Result<Value, TestError> {
self.call_tool("dom_snapshot", json!({})).await
}
pub async fn dom_snapshot_for(&mut self, label: &str) -> Result<Value, TestError> {
self.call_tool("dom_snapshot", json!({"webview_label": label}))
.await
}
pub async fn screenshot_for(&mut self, label: &str) -> Result<Value, TestError> {
self.call_tool("screenshot", json!({"window_label": label}))
.await
}
pub async fn click(&mut self, ref_id: &str) -> Result<Value, TestError> {
self.call_tool("interact", json!({"action": "click", "ref_id": ref_id}))
.await
}
pub async fn fill(&mut self, ref_id: &str, value: &str) -> Result<Value, TestError> {
self.call_tool(
"input",
json!({"action": "fill", "ref_id": ref_id, "value": value}),
)
.await
}
pub async fn type_text(&mut self, ref_id: &str, text: &str) -> Result<Value, TestError> {
self.call_tool(
"input",
json!({"action": "type_text", "ref_id": ref_id, "text": text}),
)
.await
}
pub async fn list_windows(&mut self) -> Result<Value, TestError> {
self.call_tool("window", json!({"action": "list"})).await
}
pub async fn get_window_state(&mut self, label: Option<&str>) -> Result<Value, TestError> {
let mut args = json!({"action": "get_state"});
if let Some(l) = label {
args["label"] = json!(l);
}
self.call_tool("window", args).await
}
pub async fn screenshot(&mut self) -> Result<Value, TestError> {
self.call_tool("screenshot", json!({})).await
}
pub async fn screenshot_visual(
&mut self,
name: &str,
options: &VisualOptions,
) -> Result<VisualDiff, TestError> {
let result = self.screenshot().await?;
let base64_data = extract_screenshot_base64(&result)?;
crate::visual::compare_screenshot(name, &base64_data, options)
}
pub async fn invoke_command(
&mut self,
command: &str,
args: Option<Value>,
) -> Result<Value, TestError> {
let mut params = json!({"command": command});
if let Some(a) = args {
params["args"] = a;
}
self.call_tool("invoke_command", params).await
}
pub async fn get_ipc_log(&mut self, limit: Option<usize>) -> Result<Value, TestError> {
let mut args = json!({"action": "ipc"});
if let Some(n) = limit {
args["limit"] = json!(n);
}
self.call_tool("logs", args).await
}
pub async fn verify_state(
&mut self,
frontend_expr: &str,
backend_state: Value,
) -> Result<Value, TestError> {
self.call_tool(
"verify_state",
json!({
"frontend_expr": frontend_expr,
"backend_state": backend_state,
}),
)
.await
}
pub async fn detect_ghost_commands(&mut self) -> Result<Value, TestError> {
self.call_tool("detect_ghost_commands", json!({})).await
}
pub async fn check_ipc_integrity(&mut self) -> Result<Value, TestError> {
self.call_tool("check_ipc_integrity", json!({})).await
}
pub async fn assert_semantic(
&mut self,
expression: &str,
label: &str,
condition: &str,
expected: Value,
) -> Result<Value, TestError> {
self.call_tool(
"assert_semantic",
json!({
"expression": expression,
"label": label,
"condition": condition,
"expected": expected,
}),
)
.await
}
pub async fn audit_accessibility(&mut self) -> Result<Value, TestError> {
self.call_tool("inspect", json!({"action": "audit_accessibility"}))
.await
}
pub async fn get_performance_metrics(&mut self) -> Result<Value, TestError> {
self.call_tool("inspect", json!({"action": "get_performance"}))
.await
}
pub async fn get_registry(&mut self) -> Result<Value, TestError> {
self.call_tool("get_registry", json!({})).await
}
pub async fn get_memory_stats(&mut self) -> Result<Value, TestError> {
self.call_tool("get_memory_stats", json!({})).await
}
pub async fn get_plugin_info(&mut self) -> Result<Value, TestError> {
self.call_tool("get_plugin_info", json!({})).await
}
pub async fn get_diagnostics(&mut self) -> Result<Value, TestError> {
self.call_tool("get_diagnostics", json!({})).await
}
pub async fn wait_for(
&mut self,
condition: &str,
value: Option<&str>,
timeout_ms: Option<u64>,
poll_ms: Option<u64>,
) -> Result<Value, TestError> {
let mut args = json!({"condition": condition});
if let Some(v) = value {
args["value"] = json!(v);
}
if let Some(t) = timeout_ms {
args["timeout_ms"] = json!(t);
}
if let Some(p) = poll_ms {
args["poll_ms"] = json!(p);
}
self.call_tool("wait_for", args).await
}
pub async fn start_recording(&mut self, session_id: Option<&str>) -> Result<Value, TestError> {
let mut args = json!({"action": "start"});
if let Some(id) = session_id {
args["session_id"] = json!(id);
}
self.call_tool("recording", args).await
}
pub async fn stop_recording(&mut self) -> Result<Value, TestError> {
self.call_tool("recording", json!({"action": "stop"})).await
}
pub async fn export_session(&mut self) -> Result<Value, TestError> {
self.call_tool("recording", json!({"action": "export"}))
.await
}
pub async fn find_elements(&mut self, query: Value) -> Result<Value, TestError> {
self.call_tool("find_elements", query).await
}
pub async fn double_click(&mut self, ref_id: &str) -> Result<Value, TestError> {
self.call_tool(
"interact",
json!({"action": "double_click", "ref_id": ref_id}),
)
.await
}
pub async fn hover(&mut self, ref_id: &str) -> Result<Value, TestError> {
self.call_tool("interact", json!({"action": "hover", "ref_id": ref_id}))
.await
}
pub async fn focus(&mut self, ref_id: &str) -> Result<Value, TestError> {
self.call_tool("interact", json!({"action": "focus", "ref_id": ref_id}))
.await
}
pub async fn press_key(&mut self, key: &str) -> Result<Value, TestError> {
self.call_tool("input", json!({"action": "press_key", "key": key}))
.await
}
pub async fn navigate(&mut self, url: &str) -> Result<Value, TestError> {
self.call_tool("navigate", json!({"action": "go_to", "url": url}))
.await
}
pub async fn logs(&mut self, action: &str, limit: Option<usize>) -> Result<Value, TestError> {
self.call_tool("logs", json!({"action": action, "limit": limit}))
.await
}
pub async fn scroll_to(&mut self, ref_id: &str) -> Result<Value, TestError> {
self.call_tool(
"interact",
json!({"action": "scroll_into_view", "ref_id": ref_id}),
)
.await
}
pub async fn select_option(
&mut self,
ref_id: &str,
values: &[&str],
) -> Result<Value, TestError> {
self.call_tool(
"interact",
json!({"action": "select_option", "ref_id": ref_id, "values": values}),
)
.await
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.base_url
}
#[must_use]
pub fn host(&self) -> &str {
&self.host
}
#[must_use]
pub fn port(&self) -> u16 {
self.port
}
#[must_use]
pub fn session_id(&self) -> &str {
&self.session_id
}
pub(crate) fn http_client(&self) -> &reqwest::Client {
&self.http
}
#[deprecated(since = "0.2.0", note = "renamed to get_ipc_calls_for")]
pub async fn get_ipc_calls(&mut self, command: &str) -> Result<Vec<Value>, TestError> {
let log = self.get_ipc_log(None).await?;
let entries = if let Some(arr) = log.as_array() {
arr.clone()
} else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
entries.clone()
} else {
return Ok(Vec::new());
};
Ok(entries
.into_iter()
.filter(|e| {
e.get("command")
.and_then(Value::as_str)
.is_some_and(|c| c == command)
})
.collect())
}
#[deprecated(since = "0.2.0", note = "renamed to get_ipc_calls_since")]
pub async fn ipc_calls_since(&mut self, checkpoint: usize) -> Result<Vec<Value>, TestError> {
let log = self.get_ipc_log(None).await?;
let entries = if let Some(arr) = log.as_array() {
arr.clone()
} else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
entries.clone()
} else {
return Ok(Vec::new());
};
Ok(entries.into_iter().skip(checkpoint).collect())
}
pub async fn get_ipc_calls_for(&mut self, command: &str) -> Result<Vec<Value>, TestError> {
#[allow(deprecated)]
self.get_ipc_calls(command).await
}
pub async fn get_ipc_calls_since(
&mut self,
checkpoint: usize,
) -> Result<Vec<Value>, TestError> {
#[allow(deprecated)]
self.ipc_calls_since(checkpoint).await
}
pub fn wait(&mut self, condition: &str) -> WaitForBuilder<'_> {
WaitForBuilder {
client: self,
condition: condition.to_string(),
value: None,
timeout_ms: 10_000,
poll_ms: 200,
}
}
#[deprecated(since = "0.2.0", note = "renamed to create_ipc_checkpoint")]
pub async fn ipc_checkpoint(&mut self) -> Result<usize, TestError> {
self.create_ipc_checkpoint().await
}
pub async fn create_ipc_checkpoint(&mut self) -> Result<usize, TestError> {
let log = self.get_ipc_log(None).await?;
let len = if let Some(arr) = log.as_array() {
arr.len()
} else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
entries.len()
} else {
0
};
Ok(len)
}
pub async fn plugin_info(&mut self) -> Result<PluginInfo, TestError> {
let value = self.get_plugin_info().await?;
serde_json::from_value(value)
.map_err(|e| TestError::Other(format!("failed to deserialize PluginInfo: {e}")))
}
pub async fn memory_stats(&mut self) -> Result<MemoryStats, TestError> {
let value = self.get_memory_stats().await?;
serde_json::from_value(value)
.map_err(|e| TestError::Other(format!("failed to deserialize MemoryStats: {e}")))
}
pub fn verify(&mut self) -> VerifyBuilder<'_> {
VerifyBuilder::new(self)
}
pub async fn click_by_text(&mut self, text: &str) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_text(text).await?;
self.click(&ref_id).await
}
pub async fn click_by_id(&mut self, id: &str) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_id(id).await?;
self.click(&ref_id).await
}
pub async fn double_click_by_text(&mut self, text: &str) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_text(text).await?;
self.double_click(&ref_id).await
}
pub async fn double_click_by_id(&mut self, id: &str) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_id(id).await?;
self.double_click(&ref_id).await
}
pub async fn double_click_by_selector(&mut self, selector: &str) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_selector(selector).await?;
self.double_click(&ref_id).await
}
pub async fn click_by_selector(&mut self, selector: &str) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_selector(selector).await?;
self.click(&ref_id).await
}
pub async fn fill_by_id(&mut self, id: &str, value: &str) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_id(id).await?;
self.fill(&ref_id, value).await
}
pub async fn fill_by_text(&mut self, text: &str, value: &str) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_text(text).await?;
self.fill(&ref_id, value).await
}
pub async fn fill_by_selector(
&mut self,
selector: &str,
value: &str,
) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_selector(selector).await?;
self.fill(&ref_id, value).await
}
pub async fn type_by_id(&mut self, id: &str, text: &str) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_id(id).await?;
self.type_text(&ref_id, text).await
}
pub async fn expect_text(&mut self, text: &str) -> Result<(), TestError> {
self.expect_text_with_timeout(text, 5000).await
}
pub async fn expect_text_with_timeout(
&mut self,
text: &str,
timeout_ms: u64,
) -> Result<(), TestError> {
let result = self
.wait_for("text", Some(text), Some(timeout_ms), Some(200))
.await?;
if result.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(())
} else {
Err(TestError::Timeout(format!(
"text \"{text}\" did not appear within {timeout_ms}ms"
)))
}
}
pub async fn expect_no_text(&mut self, text: &str) -> Result<(), TestError> {
let result = self
.wait_for("text_gone", Some(text), Some(3000), Some(200))
.await?;
if result.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(())
} else {
Err(TestError::Timeout(format!(
"text \"{text}\" still present after 3000ms"
)))
}
}
pub async fn select_by_id(&mut self, id: &str, value: &str) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_id(id).await?;
self.select_option(&ref_id, &[value]).await
}
pub async fn select_option_by_id(
&mut self,
id: &str,
values: &[&str],
) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_id(id).await?;
self.select_option(&ref_id, values).await
}
pub async fn select_option_by_text(
&mut self,
text: &str,
values: &[&str],
) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_text(text).await?;
self.select_option(&ref_id, values).await
}
pub async fn select_option_by_selector(
&mut self,
selector: &str,
values: &[&str],
) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_selector(selector).await?;
self.select_option(&ref_id, values).await
}
pub async fn scroll_to_by_selector(&mut self, selector: &str) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_selector(selector).await?;
self.scroll_to(&ref_id).await
}
pub async fn scroll_to_by_id(&mut self, id: &str) -> Result<Value, TestError> {
let ref_id = self.find_ref_by_id(id).await?;
self.scroll_to(&ref_id).await
}
pub async fn text_by_id(&mut self, id: &str) -> Result<String, TestError> {
let snap = self.snapshot_json().await?;
let tree = &snap["tree"];
find_text_by_attr_id(tree, id)
.ok_or_else(|| TestError::ElementNotFound(format!("id=\"{id}\"")))
}
async fn snapshot_json(&mut self) -> Result<Value, TestError> {
self.call_tool("dom_snapshot", json!({"format": "json"}))
.await
}
async fn find_ref_by_text(&mut self, text: &str) -> Result<String, TestError> {
let snap = self.snapshot_json().await?;
let tree = &snap["tree"];
find_in_tree_by_text(tree, text)
.ok_or_else(|| TestError::ElementNotFound(format!("text=\"{text}\"")))
}
async fn find_ref_by_id(&mut self, id: &str) -> Result<String, TestError> {
let snap = self.snapshot_json().await?;
let tree = &snap["tree"];
find_in_tree_by_attr_id(tree, id)
.ok_or_else(|| TestError::ElementNotFound(format!("id=\"{id}\"")))
}
async fn find_ref_by_selector(&mut self, selector: &str) -> Result<String, TestError> {
let result = self.find_elements(json!({"selector": selector})).await?;
let elements = result
.as_array()
.or_else(|| result.get("elements").and_then(Value::as_array));
if let Some(elems) = elements
&& let Some(first) = elems.first()
&& let Some(ref_id) = first.get("ref_id").and_then(Value::as_str)
{
return Ok(ref_id.to_string());
}
Err(TestError::ElementNotFound(format!(
"selector=\"{selector}\""
)))
}
#[must_use]
pub fn get_by_role(&self, role: &str) -> crate::locator::Locator {
crate::locator::Locator::role(role)
}
#[must_use]
pub fn get_by_text(&self, text: &str) -> crate::locator::Locator {
crate::locator::Locator::text(text)
}
#[must_use]
pub fn get_by_test_id(&self, id: &str) -> crate::locator::Locator {
crate::locator::Locator::test_id(id)
}
#[must_use]
pub fn get_by_label(&self, text: &str) -> crate::locator::Locator {
crate::locator::Locator::label(text)
}
#[must_use]
pub fn get_by_placeholder(&self, text: &str) -> crate::locator::Locator {
crate::locator::Locator::placeholder(text)
}
#[must_use]
pub fn locator(&self, css: &str) -> crate::locator::Locator {
crate::locator::Locator::css(css)
}
#[must_use]
pub fn get_by_alt_text(&self, alt: &str) -> crate::locator::Locator {
crate::locator::Locator::alt_text(alt)
}
#[must_use]
pub fn get_by_title(&self, title: &str) -> crate::locator::Locator {
crate::locator::Locator::title(title)
}
pub async fn screenshot_to_file(
&mut self,
path: impl AsRef<std::path::Path>,
) -> Result<std::path::PathBuf, TestError> {
let result = self.screenshot().await?;
let base64_data = extract_screenshot_base64(&result)?;
save_screenshot_to_file(&base64_data, path.as_ref())
}
pub async fn screenshot_to_file_for(
&mut self,
label: &str,
path: impl AsRef<std::path::Path>,
) -> Result<std::path::PathBuf, TestError> {
let result = self.screenshot_for(label).await?;
let base64_data = extract_screenshot_base64(&result)?;
save_screenshot_to_file(&base64_data, path.as_ref())
}
}
fn save_screenshot_to_file(
base64_data: &str,
path: &std::path::Path,
) -> Result<std::path::PathBuf, TestError> {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(base64_data)
.map_err(|e| TestError::Other(format!("failed to decode screenshot base64: {e}")))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| TestError::Other(format!("failed to create directory: {e}")))?;
}
std::fs::write(path, &bytes)
.map_err(|e| TestError::Other(format!("failed to write screenshot: {e}")))?;
path.canonicalize()
.or_else(|_| Ok(path.to_path_buf()))
.map_err(|e: std::io::Error| TestError::Other(format!("path error: {e}")))
}
fn extract_screenshot_base64(result: &Value) -> Result<String, TestError> {
if let Some(data) = result.get("base64").and_then(Value::as_str) {
return Ok(data.to_string());
}
if let Some(data) = result.get("data").and_then(Value::as_str) {
return Ok(data.to_string());
}
if let Some(data) = result.get("image").and_then(Value::as_str) {
return Ok(data.to_string());
}
if let Some(data) = result
.pointer("/result/content/0/data")
.and_then(Value::as_str)
{
return Ok(data.to_string());
}
Err(TestError::Other(
"screenshot result does not contain recognizable base64 image data".to_string(),
))
}
fn find_in_tree_by_text(node: &Value, text: &str) -> Option<String> {
let node_text = node.get("text").and_then(Value::as_str).unwrap_or("");
let node_name = node.get("name").and_then(Value::as_str).unwrap_or("");
if (node_text.contains(text) || node_name.contains(text))
&& let Some(ref_id) = node.get("ref_id").and_then(Value::as_str)
{
return Some(ref_id.to_string());
}
if let Some(children) = node.get("children").and_then(Value::as_array) {
for child in children {
if let Some(found) = find_in_tree_by_text(child, text) {
return Some(found);
}
}
}
None
}
fn find_in_tree_by_attr_id(node: &Value, id: &str) -> Option<String> {
if node
.get("attributes")
.and_then(|a| a.get("id"))
.and_then(Value::as_str)
== Some(id)
&& let Some(ref_id) = node.get("ref_id").and_then(Value::as_str)
{
return Some(ref_id.to_string());
}
if let Some(children) = node.get("children").and_then(Value::as_array) {
for child in children {
if let Some(found) = find_in_tree_by_attr_id(child, id) {
return Some(found);
}
}
}
None
}
fn find_text_by_attr_id(node: &Value, id: &str) -> Option<String> {
if node
.get("attributes")
.and_then(|a| a.get("id"))
.and_then(Value::as_str)
== Some(id)
{
let text = node.get("text").and_then(Value::as_str).unwrap_or("");
return Some(text.to_string());
}
if let Some(children) = node.get("children").and_then(Value::as_array) {
for child in children {
if let Some(found) = find_text_by_attr_id(child, id) {
return Some(found);
}
}
}
None
}
pub fn assert_json_eq(value: &Value, pointer: &str, expected: &Value) {
let actual = value.pointer(pointer);
assert!(
actual == Some(expected),
"JSON pointer {pointer}: expected {expected}, got {}",
actual.map_or("missing".to_string(), std::string::ToString::to_string)
);
}
pub fn assert_json_truthy(value: &Value, pointer: &str) {
let actual = value.pointer(pointer);
let is_truthy = match actual {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(Value::Number(n)) => n.as_f64().unwrap_or(0.0) != 0.0,
Some(Value::String(s)) => !s.is_empty(),
Some(Value::Array(a)) => !a.is_empty(),
Some(Value::Object(_)) => true,
};
assert!(
is_truthy,
"JSON pointer {pointer}: expected truthy, got {}",
actual.map_or("missing".to_string(), std::string::ToString::to_string)
);
}
pub fn assert_no_a11y_violations(audit: &Value) {
let violations = audit
.pointer("/summary/violations")
.and_then(serde_json::Value::as_u64)
.unwrap_or(u64::MAX);
assert_eq!(
violations, 0,
"expected 0 accessibility violations, got {violations}"
);
}
pub fn assert_performance_budget(metrics: &Value, max_load_ms: f64, max_heap_mb: f64) {
if let Some(load) = metrics
.pointer("/navigation/load_event_ms")
.and_then(serde_json::Value::as_f64)
{
assert!(
load <= max_load_ms,
"load event took {load}ms, budget is {max_load_ms}ms"
);
}
if let Some(heap) = metrics
.pointer("/js_heap/used_mb")
.and_then(serde_json::Value::as_f64)
{
assert!(
heap <= max_heap_mb,
"JS heap is {heap}MB, budget is {max_heap_mb}MB"
);
}
}
pub fn assert_ipc_healthy(integrity: &Value) {
let healthy = integrity
.get("healthy")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
assert!(
healthy,
"IPC integrity check failed: {}",
serde_json::to_string_pretty(integrity).unwrap_or_default()
);
}
pub fn assert_state_matches(verification: &Value) {
let passed = verification
.get("passed")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
assert!(
passed,
"state verification failed: {}",
serde_json::to_string_pretty(verification).unwrap_or_default()
);
}