* Native Rust rewrite of agent-browser daemon Single-binary Rust implementation replacing the Node.js/Playwright daemon with direct CDP (Chrome DevTools Protocol) communication. Includes full command parity, WebDriver/Safari/iOS backend routing, request tracking, frame context management, CDP protocol codegen, and comprehensive tests. * improvements * fix ci * fixes * faster builds
719 lines
21 KiB
Rust
719 lines
21 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use serde_json::Value;
|
|
|
|
use super::cdp::client::CdpClient;
|
|
use super::cdp::types::*;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct RefEntry {
|
|
pub backend_node_id: Option<i64>,
|
|
pub role: String,
|
|
pub name: String,
|
|
pub nth: Option<usize>,
|
|
pub selector: Option<String>,
|
|
}
|
|
|
|
pub struct RefMap {
|
|
map: HashMap<String, RefEntry>,
|
|
next_ref: usize,
|
|
}
|
|
|
|
impl RefMap {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
map: HashMap::new(),
|
|
next_ref: 1,
|
|
}
|
|
}
|
|
|
|
pub fn add(
|
|
&mut self,
|
|
ref_id: String,
|
|
backend_node_id: Option<i64>,
|
|
role: &str,
|
|
name: &str,
|
|
nth: Option<usize>,
|
|
) {
|
|
self.map.insert(
|
|
ref_id,
|
|
RefEntry {
|
|
backend_node_id,
|
|
role: role.to_string(),
|
|
name: name.to_string(),
|
|
nth,
|
|
selector: None,
|
|
},
|
|
);
|
|
}
|
|
|
|
pub fn get(&self, ref_id: &str) -> Option<&RefEntry> {
|
|
self.map.get(ref_id)
|
|
}
|
|
|
|
pub fn clear(&mut self) {
|
|
self.map.clear();
|
|
self.next_ref = 1;
|
|
}
|
|
|
|
pub fn next_ref_num(&self) -> usize {
|
|
self.next_ref
|
|
}
|
|
|
|
pub fn set_next_ref_num(&mut self, n: usize) {
|
|
self.next_ref = n;
|
|
}
|
|
}
|
|
|
|
pub fn parse_ref(input: &str) -> Option<String> {
|
|
let trimmed = input.trim();
|
|
|
|
if let Some(stripped) = trimmed.strip_prefix('@') {
|
|
if stripped.starts_with('e') && stripped[1..].chars().all(|c| c.is_ascii_digit()) {
|
|
return Some(stripped.to_string());
|
|
}
|
|
}
|
|
|
|
if let Some(stripped) = trimmed.strip_prefix("ref=") {
|
|
if stripped.starts_with('e') && stripped[1..].chars().all(|c| c.is_ascii_digit()) {
|
|
return Some(stripped.to_string());
|
|
}
|
|
}
|
|
|
|
if trimmed.starts_with('e')
|
|
&& trimmed.len() > 1
|
|
&& trimmed[1..].chars().all(|c| c.is_ascii_digit())
|
|
{
|
|
return Some(trimmed.to_string());
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
pub async fn resolve_element_center(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
ref_map: &RefMap,
|
|
selector_or_ref: &str,
|
|
) -> Result<(f64, f64), String> {
|
|
if let Some(ref_id) = parse_ref(selector_or_ref) {
|
|
let entry = ref_map
|
|
.get(&ref_id)
|
|
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
|
|
|
|
if let Some(backend_node_id) = entry.backend_node_id {
|
|
let result: DomGetBoxModelResult = client
|
|
.send_command_typed(
|
|
"DOM.getBoxModel",
|
|
&DomGetBoxModelParams {
|
|
backend_node_id: Some(backend_node_id),
|
|
node_id: None,
|
|
object_id: None,
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
return Ok(box_model_center(&result.model));
|
|
}
|
|
|
|
// Fallback: use role/name to find via JS
|
|
return resolve_by_role_name(client, session_id, &entry.role, &entry.name, entry.nth).await;
|
|
}
|
|
|
|
// CSS selector
|
|
resolve_by_selector(client, session_id, selector_or_ref).await
|
|
}
|
|
|
|
pub async fn resolve_element_object_id(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
ref_map: &RefMap,
|
|
selector_or_ref: &str,
|
|
) -> Result<String, String> {
|
|
if let Some(ref_id) = parse_ref(selector_or_ref) {
|
|
let entry = ref_map
|
|
.get(&ref_id)
|
|
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
|
|
|
|
if let Some(backend_node_id) = entry.backend_node_id {
|
|
let result: DomResolveNodeResult = client
|
|
.send_command_typed(
|
|
"DOM.resolveNode",
|
|
&DomResolveNodeParams {
|
|
backend_node_id: Some(backend_node_id),
|
|
node_id: None,
|
|
object_group: Some("agent-browser".to_string()),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
return result
|
|
.object
|
|
.object_id
|
|
.ok_or_else(|| format!("No objectId for ref {}", ref_id));
|
|
}
|
|
}
|
|
|
|
// CSS selector fallback
|
|
let js = format!(
|
|
"document.querySelector({})",
|
|
serde_json::to_string(selector_or_ref).unwrap_or_default()
|
|
);
|
|
let result: EvaluateResult = client
|
|
.send_command_typed(
|
|
"Runtime.evaluate",
|
|
&EvaluateParams {
|
|
expression: js,
|
|
return_by_value: Some(false),
|
|
await_promise: Some(false),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
result
|
|
.result
|
|
.object_id
|
|
.ok_or_else(|| format!("Element not found: {}", selector_or_ref))
|
|
}
|
|
|
|
async fn resolve_by_role_name(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
role: &str,
|
|
name: &str,
|
|
nth: Option<usize>,
|
|
) -> Result<(f64, f64), String> {
|
|
let nth_index = nth.unwrap_or(0);
|
|
let js = format!(
|
|
r#"(() => {{
|
|
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
|
const matches = [];
|
|
let node;
|
|
while (node = walker.nextNode()) {{
|
|
const r = node.getAttribute('role') || node.tagName.toLowerCase();
|
|
const n = node.getAttribute('aria-label') || node.textContent.trim().slice(0, 100);
|
|
if (r === {role} && n === {name}) matches.push(node);
|
|
}}
|
|
const el = matches[{nth}];
|
|
if (!el) return null;
|
|
const rect = el.getBoundingClientRect();
|
|
return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }};
|
|
}})()"#,
|
|
role = serde_json::to_string(role).unwrap_or_default(),
|
|
name = serde_json::to_string(name).unwrap_or_default(),
|
|
nth = nth_index,
|
|
);
|
|
|
|
let result: EvaluateResult = client
|
|
.send_command_typed(
|
|
"Runtime.evaluate",
|
|
&EvaluateParams {
|
|
expression: js,
|
|
return_by_value: Some(true),
|
|
await_promise: Some(false),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
let val = result.result.value.unwrap_or(Value::Null);
|
|
let x = val.get("x").and_then(|v| v.as_f64());
|
|
let y = val.get("y").and_then(|v| v.as_f64());
|
|
|
|
match (x, y) {
|
|
(Some(x), Some(y)) => Ok((x, y)),
|
|
_ => Err(format!(
|
|
"Could not locate element with role={} name={}",
|
|
role, name
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn resolve_by_selector(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
selector: &str,
|
|
) -> Result<(f64, f64), String> {
|
|
let js = format!(
|
|
r#"(() => {{
|
|
const el = document.querySelector({sel});
|
|
if (!el) return null;
|
|
const rect = el.getBoundingClientRect();
|
|
return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }};
|
|
}})()"#,
|
|
sel = serde_json::to_string(selector).unwrap_or_default(),
|
|
);
|
|
|
|
let result: EvaluateResult = client
|
|
.send_command_typed(
|
|
"Runtime.evaluate",
|
|
&EvaluateParams {
|
|
expression: js,
|
|
return_by_value: Some(true),
|
|
await_promise: Some(false),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
let val = result.result.value.unwrap_or(Value::Null);
|
|
let x = val.get("x").and_then(|v| v.as_f64());
|
|
let y = val.get("y").and_then(|v| v.as_f64());
|
|
|
|
match (x, y) {
|
|
(Some(x), Some(y)) => Ok((x, y)),
|
|
_ => Err(format!("Element not found: {}", selector)),
|
|
}
|
|
}
|
|
|
|
fn box_model_center(model: &BoxModel) -> (f64, f64) {
|
|
// content quad: [x1,y1, x2,y2, x3,y3, x4,y4]
|
|
if model.content.len() >= 8 {
|
|
let x = (model.content[0] + model.content[2] + model.content[4] + model.content[6]) / 4.0;
|
|
let y = (model.content[1] + model.content[3] + model.content[5] + model.content[7]) / 4.0;
|
|
(x, y)
|
|
} else {
|
|
(0.0, 0.0)
|
|
}
|
|
}
|
|
|
|
pub async fn get_element_text(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
ref_map: &RefMap,
|
|
selector_or_ref: &str,
|
|
) -> Result<String, String> {
|
|
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
|
|
|
let result: EvaluateResult = client
|
|
.send_command_typed(
|
|
"Runtime.callFunctionOn",
|
|
&CallFunctionOnParams {
|
|
function_declaration:
|
|
"function() { return this.innerText || this.textContent || ''; }".to_string(),
|
|
object_id: Some(object_id),
|
|
arguments: None,
|
|
return_by_value: Some(true),
|
|
await_promise: Some(false),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
Ok(result
|
|
.result
|
|
.value
|
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
|
.unwrap_or_default())
|
|
}
|
|
|
|
pub async fn get_element_attribute(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
ref_map: &RefMap,
|
|
selector_or_ref: &str,
|
|
attribute: &str,
|
|
) -> Result<Value, String> {
|
|
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
|
|
|
let result: EvaluateResult = client
|
|
.send_command_typed(
|
|
"Runtime.callFunctionOn",
|
|
&CallFunctionOnParams {
|
|
function_declaration: format!(
|
|
"function() {{ return this.getAttribute({}); }}",
|
|
serde_json::to_string(attribute).unwrap_or_default()
|
|
),
|
|
object_id: Some(object_id),
|
|
arguments: None,
|
|
return_by_value: Some(true),
|
|
await_promise: Some(false),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
Ok(result.result.value.unwrap_or(Value::Null))
|
|
}
|
|
|
|
pub async fn is_element_visible(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
ref_map: &RefMap,
|
|
selector_or_ref: &str,
|
|
) -> Result<bool, String> {
|
|
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
|
|
|
let result: EvaluateResult = client
|
|
.send_command_typed(
|
|
"Runtime.callFunctionOn",
|
|
&CallFunctionOnParams {
|
|
function_declaration: r#"function() {
|
|
const rect = this.getBoundingClientRect();
|
|
const style = window.getComputedStyle(this);
|
|
return rect.width > 0 && rect.height > 0 &&
|
|
style.visibility !== 'hidden' &&
|
|
style.display !== 'none' &&
|
|
parseFloat(style.opacity) > 0;
|
|
}"#
|
|
.to_string(),
|
|
object_id: Some(object_id),
|
|
arguments: None,
|
|
return_by_value: Some(true),
|
|
await_promise: Some(false),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
Ok(result
|
|
.result
|
|
.value
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false))
|
|
}
|
|
|
|
pub async fn is_element_enabled(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
ref_map: &RefMap,
|
|
selector_or_ref: &str,
|
|
) -> Result<bool, String> {
|
|
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
|
|
|
let result: EvaluateResult = client
|
|
.send_command_typed(
|
|
"Runtime.callFunctionOn",
|
|
&CallFunctionOnParams {
|
|
function_declaration: "function() { return !this.disabled; }".to_string(),
|
|
object_id: Some(object_id),
|
|
arguments: None,
|
|
return_by_value: Some(true),
|
|
await_promise: Some(false),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
Ok(result
|
|
.result
|
|
.value
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(true))
|
|
}
|
|
|
|
pub async fn is_element_checked(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
ref_map: &RefMap,
|
|
selector_or_ref: &str,
|
|
) -> Result<bool, String> {
|
|
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
|
|
|
let result: EvaluateResult = client
|
|
.send_command_typed(
|
|
"Runtime.callFunctionOn",
|
|
&CallFunctionOnParams {
|
|
function_declaration: "function() { return !!this.checked; }".to_string(),
|
|
object_id: Some(object_id),
|
|
arguments: None,
|
|
return_by_value: Some(true),
|
|
await_promise: Some(false),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
Ok(result
|
|
.result
|
|
.value
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false))
|
|
}
|
|
|
|
pub async fn get_element_inner_text(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
ref_map: &RefMap,
|
|
selector_or_ref: &str,
|
|
) -> Result<String, String> {
|
|
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
|
|
|
let result: EvaluateResult = client
|
|
.send_command_typed(
|
|
"Runtime.callFunctionOn",
|
|
&CallFunctionOnParams {
|
|
function_declaration: "function() { return this.innerText || ''; }".to_string(),
|
|
object_id: Some(object_id),
|
|
arguments: None,
|
|
return_by_value: Some(true),
|
|
await_promise: Some(false),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
Ok(result
|
|
.result
|
|
.value
|
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
|
.unwrap_or_default())
|
|
}
|
|
|
|
pub async fn get_element_inner_html(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
ref_map: &RefMap,
|
|
selector_or_ref: &str,
|
|
) -> Result<String, String> {
|
|
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
|
|
|
let result: EvaluateResult = client
|
|
.send_command_typed(
|
|
"Runtime.callFunctionOn",
|
|
&CallFunctionOnParams {
|
|
function_declaration: "function() { return this.innerHTML || ''; }".to_string(),
|
|
object_id: Some(object_id),
|
|
arguments: None,
|
|
return_by_value: Some(true),
|
|
await_promise: Some(false),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
Ok(result
|
|
.result
|
|
.value
|
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
|
.unwrap_or_default())
|
|
}
|
|
|
|
pub async fn get_element_input_value(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
ref_map: &RefMap,
|
|
selector_or_ref: &str,
|
|
) -> Result<String, String> {
|
|
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
|
|
|
let result: EvaluateResult = client
|
|
.send_command_typed(
|
|
"Runtime.callFunctionOn",
|
|
&CallFunctionOnParams {
|
|
function_declaration:
|
|
"function() { return typeof this.value === 'string' ? this.value : ''; }"
|
|
.to_string(),
|
|
object_id: Some(object_id),
|
|
arguments: None,
|
|
return_by_value: Some(true),
|
|
await_promise: Some(false),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
Ok(result
|
|
.result
|
|
.value
|
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
|
.unwrap_or_default())
|
|
}
|
|
|
|
pub async fn set_element_value(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
ref_map: &RefMap,
|
|
selector_or_ref: &str,
|
|
value: &str,
|
|
) -> Result<(), String> {
|
|
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
|
|
|
let js = format!(
|
|
"function() {{ this.value = {}; this.dispatchEvent(new Event('input', {{bubbles: true}})); this.dispatchEvent(new Event('change', {{bubbles: true}})); }}",
|
|
serde_json::to_string(value).unwrap_or_default()
|
|
);
|
|
|
|
client
|
|
.send_command_typed::<_, EvaluateResult>(
|
|
"Runtime.callFunctionOn",
|
|
&CallFunctionOnParams {
|
|
function_declaration: js,
|
|
object_id: Some(object_id),
|
|
arguments: None,
|
|
return_by_value: Some(true),
|
|
await_promise: Some(false),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_element_bounding_box(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
ref_map: &RefMap,
|
|
selector_or_ref: &str,
|
|
) -> Result<Value, String> {
|
|
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
|
|
|
let result: EvaluateResult = client
|
|
.send_command_typed(
|
|
"Runtime.callFunctionOn",
|
|
&CallFunctionOnParams {
|
|
function_declaration: r#"function() {
|
|
const r = this.getBoundingClientRect();
|
|
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
}"#
|
|
.to_string(),
|
|
object_id: Some(object_id),
|
|
arguments: None,
|
|
return_by_value: Some(true),
|
|
await_promise: Some(false),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
result
|
|
.result
|
|
.value
|
|
.ok_or_else(|| format!("Could not get bounding box for: {}", selector_or_ref))
|
|
}
|
|
|
|
pub async fn get_element_count(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
selector: &str,
|
|
) -> Result<i64, String> {
|
|
let js = format!(
|
|
"document.querySelectorAll({}).length",
|
|
serde_json::to_string(selector).unwrap_or_default()
|
|
);
|
|
|
|
let result: EvaluateResult = client
|
|
.send_command_typed(
|
|
"Runtime.evaluate",
|
|
&EvaluateParams {
|
|
expression: js,
|
|
return_by_value: Some(true),
|
|
await_promise: Some(false),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
Ok(result.result.value.and_then(|v| v.as_i64()).unwrap_or(0))
|
|
}
|
|
|
|
pub async fn get_element_styles(
|
|
client: &CdpClient,
|
|
session_id: &str,
|
|
ref_map: &RefMap,
|
|
selector_or_ref: &str,
|
|
properties: Option<Vec<String>>,
|
|
) -> Result<Value, String> {
|
|
let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?;
|
|
|
|
let js = match properties {
|
|
Some(props) => {
|
|
let props_json = serde_json::to_string(&props).unwrap_or("[]".to_string());
|
|
format!(
|
|
r#"function() {{
|
|
const s = window.getComputedStyle(this);
|
|
const props = {};
|
|
const result = {{}};
|
|
for (const p of props) result[p] = s.getPropertyValue(p);
|
|
return result;
|
|
}}"#,
|
|
props_json
|
|
)
|
|
}
|
|
None => r#"function() {
|
|
const s = window.getComputedStyle(this);
|
|
const result = {};
|
|
for (let i = 0; i < s.length; i++) {
|
|
const p = s[i];
|
|
result[p] = s.getPropertyValue(p);
|
|
}
|
|
return result;
|
|
}"#
|
|
.to_string(),
|
|
};
|
|
|
|
let result: EvaluateResult = client
|
|
.send_command_typed(
|
|
"Runtime.callFunctionOn",
|
|
&CallFunctionOnParams {
|
|
function_declaration: js,
|
|
object_id: Some(object_id),
|
|
arguments: None,
|
|
return_by_value: Some(true),
|
|
await_promise: Some(false),
|
|
},
|
|
Some(session_id),
|
|
)
|
|
.await?;
|
|
|
|
Ok(result.result.value.unwrap_or(Value::Null))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_ref_at_prefix() {
|
|
assert_eq!(parse_ref("@e1"), Some("e1".to_string()));
|
|
assert_eq!(parse_ref("@e123"), Some("e123".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_ref_equals_prefix() {
|
|
assert_eq!(parse_ref("ref=e1"), Some("e1".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_ref_bare() {
|
|
assert_eq!(parse_ref("e1"), Some("e1".to_string()));
|
|
assert_eq!(parse_ref("e42"), Some("e42".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_ref_invalid() {
|
|
assert_eq!(parse_ref("button"), None);
|
|
assert_eq!(parse_ref("e"), None);
|
|
assert_eq!(parse_ref("1"), None);
|
|
assert_eq!(parse_ref(""), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ref_map_basic() {
|
|
let mut map = RefMap::new();
|
|
map.add("e1".to_string(), Some(42), "button", "Submit", None);
|
|
assert!(map.get("e1").is_some());
|
|
assert_eq!(map.get("e1").unwrap().role, "button");
|
|
assert!(map.get("e2").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_box_model_center() {
|
|
let model = BoxModel {
|
|
content: vec![10.0, 20.0, 110.0, 20.0, 110.0, 60.0, 10.0, 60.0],
|
|
padding: vec![],
|
|
border: vec![],
|
|
margin: vec![],
|
|
width: 100,
|
|
height: 40,
|
|
};
|
|
let (x, y) = box_model_center(&model);
|
|
assert!((x - 60.0).abs() < 0.01);
|
|
assert!((y - 40.0).abs() < 0.01);
|
|
}
|
|
}
|