test(hands): add unit tests for BrowserHand + fix requires_approval config
Fix needs_approval field in BrowserHand::new() from false to true to match the TOML config (hands/browser.HAND.toml says requires_approval = true). Browser automation has security implications and should require approval. Add 11 unit tests covering: - Config id and enabled state - needs_approval correctness (after fix) - Action deserialization (Navigate, Click, Type, Scrape, Screenshot) - Roundtrip serialization for all major action variants - BrowserSequence builder with stop_on_error() - Multi-step sequence execution - FormField deserialization Also add stop_on_error() builder method to BrowserSequence which was referenced in the test plan but missing from the struct. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -134,7 +134,7 @@ impl BrowserHand {
|
|||||||
id: "browser".to_string(),
|
id: "browser".to_string(),
|
||||||
name: "浏览器".to_string(),
|
name: "浏览器".to_string(),
|
||||||
description: "网页浏览器自动化,支持导航、交互和数据采集".to_string(),
|
description: "网页浏览器自动化,支持导航、交互和数据采集".to_string(),
|
||||||
needs_approval: false,
|
needs_approval: true,
|
||||||
dependencies: vec!["webdriver".to_string()],
|
dependencies: vec!["webdriver".to_string()],
|
||||||
input_schema: Some(serde_json::json!({
|
input_schema: Some(serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -420,8 +420,211 @@ impl BrowserSequence {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set whether to stop on error
|
||||||
|
pub fn stop_on_error(mut self, stop: bool) -> Self {
|
||||||
|
self.stop_on_error = stop;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the sequence
|
/// Build the sequence
|
||||||
pub fn build(self) -> Vec<BrowserAction> {
|
pub fn build(self) -> Vec<BrowserAction> {
|
||||||
self.steps
|
self.steps
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::Hand;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn fresh_context() -> HandContext {
|
||||||
|
HandContext {
|
||||||
|
agent_id: zclaw_types::AgentId::new(),
|
||||||
|
working_dir: None,
|
||||||
|
env: HashMap::new(),
|
||||||
|
timeout_secs: 30,
|
||||||
|
callback_url: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_browser_config() {
|
||||||
|
let hand = BrowserHand::new();
|
||||||
|
let config = hand.config();
|
||||||
|
assert_eq!(config.id, "browser");
|
||||||
|
assert!(config.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_browser_config_needs_approval() {
|
||||||
|
let hand = BrowserHand::new();
|
||||||
|
assert!(hand.config().needs_approval, "Browser hand should require approval per TOML config");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_action_deserialize_navigate() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"action": "navigate",
|
||||||
|
"url": "https://example.com",
|
||||||
|
"wait_for": "body"
|
||||||
|
});
|
||||||
|
let action: BrowserAction = serde_json::from_value(json).expect("deserialize navigate");
|
||||||
|
match action {
|
||||||
|
BrowserAction::Navigate { url, wait_for } => {
|
||||||
|
assert_eq!(url, "https://example.com");
|
||||||
|
assert_eq!(wait_for, Some("body".to_string()));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Navigate action, got {:?}", action),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_action_deserialize_click() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"action": "click",
|
||||||
|
"selector": "#submit-btn",
|
||||||
|
"wait_ms": 500
|
||||||
|
});
|
||||||
|
let action: BrowserAction = serde_json::from_value(json).expect("deserialize click");
|
||||||
|
match action {
|
||||||
|
BrowserAction::Click { selector, wait_ms } => {
|
||||||
|
assert_eq!(selector, "#submit-btn");
|
||||||
|
assert_eq!(wait_ms, Some(500));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Click action, got {:?}", action),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_action_deserialize_type() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"action": "type",
|
||||||
|
"selector": "#search",
|
||||||
|
"text": "hello world",
|
||||||
|
"clear_first": true
|
||||||
|
});
|
||||||
|
let action: BrowserAction = serde_json::from_value(json).expect("deserialize type");
|
||||||
|
match action {
|
||||||
|
BrowserAction::Type { selector, text, clear_first } => {
|
||||||
|
assert_eq!(selector, "#search");
|
||||||
|
assert_eq!(text, "hello world");
|
||||||
|
assert!(clear_first);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Type action, got {:?}", action),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_action_deserialize_scrape() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"action": "scrape",
|
||||||
|
"selectors": ["h1", ".content", "#price"]
|
||||||
|
});
|
||||||
|
let action: BrowserAction = serde_json::from_value(json).expect("deserialize scrape");
|
||||||
|
match action {
|
||||||
|
BrowserAction::Scrape { selectors, wait_for } => {
|
||||||
|
assert_eq!(selectors, vec!["h1", ".content", "#price"]);
|
||||||
|
assert!(wait_for.is_none());
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Scrape action, got {:?}", action),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_action_deserialize_screenshot() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"action": "screenshot",
|
||||||
|
"full_page": true
|
||||||
|
});
|
||||||
|
let action: BrowserAction = serde_json::from_value(json).expect("deserialize screenshot");
|
||||||
|
match action {
|
||||||
|
BrowserAction::Screenshot { selector, full_page } => {
|
||||||
|
assert!(selector.is_none());
|
||||||
|
assert!(full_page);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Screenshot action, got {:?}", action),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_major_actions_roundtrip() {
|
||||||
|
let actions = vec![
|
||||||
|
BrowserAction::Navigate { url: "https://example.com".into(), wait_for: None },
|
||||||
|
BrowserAction::Click { selector: "#btn".into(), wait_ms: None },
|
||||||
|
BrowserAction::Type { selector: "#input".into(), text: "test".into(), clear_first: false },
|
||||||
|
BrowserAction::Scrape { selectors: vec!["h1".into()], wait_for: None },
|
||||||
|
BrowserAction::Screenshot { selector: None, full_page: false },
|
||||||
|
BrowserAction::Wait { selector: "#loaded".into(), timeout_ms: 5000 },
|
||||||
|
BrowserAction::Execute { script: "return 1".into(), args: vec![] },
|
||||||
|
BrowserAction::FillForm {
|
||||||
|
fields: vec![FormField { selector: "#name".into(), value: "Alice".into() }],
|
||||||
|
submit_selector: Some("#submit".into()),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for original in actions {
|
||||||
|
let json = serde_json::to_value(&original).expect("serialize action");
|
||||||
|
let roundtripped: BrowserAction = serde_json::from_value(json).expect("deserialize action");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&original).unwrap(),
|
||||||
|
serde_json::to_value(&roundtripped).unwrap(),
|
||||||
|
"Roundtrip failed for {:?}",
|
||||||
|
original
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_browser_sequence_builder() {
|
||||||
|
let ctx = fresh_context();
|
||||||
|
let hand = BrowserHand::new();
|
||||||
|
|
||||||
|
let sequence = BrowserSequence::new("test_sequence")
|
||||||
|
.navigate("https://example.com")
|
||||||
|
.stop_on_error(false);
|
||||||
|
|
||||||
|
assert_eq!(sequence.name, "test_sequence");
|
||||||
|
assert!(!sequence.stop_on_error);
|
||||||
|
assert_eq!(sequence.steps.len(), 1);
|
||||||
|
|
||||||
|
// Execute the navigate step
|
||||||
|
let action_json = serde_json::to_value(&sequence.steps[0]).expect("serialize step");
|
||||||
|
let result = hand.execute(&ctx, action_json).await.expect("execute");
|
||||||
|
assert!(result.success);
|
||||||
|
assert_eq!(result.output["action"], "navigate");
|
||||||
|
assert_eq!(result.output["url"], "https://example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_browser_sequence_multiple_steps() {
|
||||||
|
let ctx = fresh_context();
|
||||||
|
let hand = BrowserHand::new();
|
||||||
|
|
||||||
|
let sequence = BrowserSequence::new("multi_step")
|
||||||
|
.navigate("https://example.com")
|
||||||
|
.click("#login-btn")
|
||||||
|
.type_text("#username", "admin")
|
||||||
|
.screenshot();
|
||||||
|
|
||||||
|
assert_eq!(sequence.steps.len(), 4);
|
||||||
|
|
||||||
|
// Verify each step can execute
|
||||||
|
for (i, step) in sequence.steps.iter().enumerate() {
|
||||||
|
let action_json = serde_json::to_value(step).expect("serialize step");
|
||||||
|
let result = hand.execute(&ctx, action_json).await.expect("execute step");
|
||||||
|
assert!(result.success, "Step {} failed: {:?}", i, result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_form_field_deserialize() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"selector": "#email",
|
||||||
|
"value": "user@example.com"
|
||||||
|
});
|
||||||
|
let field: FormField = serde_json::from_value(json).expect("deserialize form field");
|
||||||
|
assert_eq!(field.selector, "#email");
|
||||||
|
assert_eq!(field.value, "user@example.com");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user