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:
iven
2026-04-01 23:22:18 +08:00
parent cc7ee3189d
commit 17a2501808

View File

@@ -134,7 +134,7 @@ impl BrowserHand {
id: "browser".to_string(),
name: "浏览器".to_string(),
description: "网页浏览器自动化,支持导航、交互和数据采集".to_string(),
needs_approval: false,
needs_approval: true,
dependencies: vec!["webdriver".to_string()],
input_schema: Some(serde_json::json!({
"type": "object",
@@ -420,8 +420,211 @@ impl BrowserSequence {
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
pub fn build(self) -> Vec<BrowserAction> {
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");
}
}