Files
claw/tests/agent_runtime_test.rs
木炎 bdf8e12246 feat: align browser callback runtime and export flows
Consolidate the browser task runtime around the callback path, add safer artifact opening for Zhihu exports, and cover the new service/browser flows with focused tests and supporting docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:44:53 +08:00

337 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
mod common;
use std::fs;
use std::net::TcpListener;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::Duration;
use common::MockTransport;
use serde_json::{json, Value};
use sgclaw::agent::{
handle_browser_message, handle_browser_message_with_context, AgentRuntimeContext,
};
use sgclaw::pipe::{AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
use sgclaw::security::MacPolicy;
use tungstenite::{accept, Message};
use uuid::Uuid;
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn temp_workspace_root() -> PathBuf {
let root = std::env::temp_dir().join(format!("sgclaw-agent-runtime-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
root
}
fn write_config(
root: &PathBuf,
api_key: &str,
base_url: &str,
model: &str,
skills_dir: Option<&str>,
) -> PathBuf {
let config_path = root.join("sgclaw_config.json");
let mut payload = json!({
"apiKey": api_key,
"baseUrl": base_url,
"model": model,
"runtimeProfile": "BrowserAttached"
});
if let Some(skills_dir) = skills_dir {
payload["skillsDir"] = json!(skills_dir);
}
fs::write(&config_path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
config_path
}
fn real_skill_lib_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.find_map(|ancestor| {
let candidate = ancestor.join("skill_lib");
candidate.is_dir().then_some(candidate)
})
.expect("workspace should have sgClaw skill_lib ancestor")
}
fn start_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let frames = Arc::new(Mutex::new(Vec::new()));
let frames_for_thread = Arc::clone(&frames);
let handle = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(1)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(1)))
.unwrap();
let mut socket = accept(stream).unwrap();
let mut action_count = 0_u64;
loop {
let message = match socket.read() {
Ok(message) => message,
Err(tungstenite::Error::ConnectionClosed)
| Err(tungstenite::Error::AlreadyClosed) => break,
Err(err) => panic!("browser ws test server read failed: {err}"),
};
let payload = match message {
Message::Text(text) => text.to_string(),
Message::Ping(payload) => {
socket.send(Message::Pong(payload)).unwrap();
continue;
}
Message::Close(_) => break,
other => panic!("expected text frame, got {other:?}"),
};
frames_for_thread.lock().unwrap().push(payload.clone());
let parsed: Value = serde_json::from_str(&payload).unwrap();
if parsed.get("type").and_then(Value::as_str) == Some("register") {
continue;
}
let values = parsed.as_array().expect("browser action frame should be an array");
let request_url = values[0].as_str().expect("request_url should be a string");
let action = values[1].as_str().expect("action should be a string");
action_count += 1;
socket
.send(Message::Text(
r#"{"type":"welcome","client_id":1,"server_time":"2026-04-04T00:00:00"}"#
.to_string()
.into(),
))
.unwrap();
socket.send(Message::Text("0".into())).unwrap();
let callback_frame = match action {
"sgHideBrowserCallAfterLoaded" => {
let target_url = values[2].as_str().expect("navigate target_url should be a string");
json!([
request_url,
"callBackJsToCpp",
format!(
"{request_url}@_@{target_url}@_@sgclaw_cb_{action_count}@_@sgHideBrowserCallAfterLoaded@_@"
)
])
}
"sgBrowserExcuteJsCodeByArea" => {
let target_url = values[2].as_str().expect("script target_url should be a string");
let response_text = if action_count == 2 {
"知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度".to_string()
} else {
r#"{"source":"https://www.zhihu.com/hot","sheet_name":"知乎热榜","columns":["rank","title","heat"],"rows":[[1,"问题一","344万"],[2,"问题二","266万"]]}"#.to_string()
};
json!([
request_url,
"callBackJsToCpp",
format!(
"{request_url}@_@{target_url}@_@sgclaw_cb_{action_count}@_@sgBrowserExcuteJsCodeByArea@_@{response_text}"
)
])
}
other => panic!("unexpected browser action {other}"),
};
socket
.send(Message::Text(callback_frame.to_string().into()))
.unwrap();
if action_count >= 3 {
break;
}
}
});
(format!("ws://{address}"), frames, handle)
}
fn test_policy() -> MacPolicy {
MacPolicy::from_json_str(
r#"{
"version": "1.0",
"domains": { "allowed": ["www.baidu.com", "www.zhihu.com"] },
"pipe_actions": {
"allowed": ["click", "type", "navigate", "getText", "eval"],
"blocked": []
}
}"#,
)
.unwrap()
}
#[test]
fn production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
std::env::set_var("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1");
std::env::remove_var("DEEPSEEK_API_KEY");
std::env::remove_var("DEEPSEEK_BASE_URL");
std::env::remove_var("DEEPSEEK_MODEL");
let workspace_root = temp_workspace_root();
let config_path = write_config(
&workspace_root,
"deepseek-test-key",
"http://127.0.0.1:9",
"deepseek-chat",
Some(real_skill_lib_root().to_str().unwrap()),
);
let (ws_url, frames, ws_handle) = start_browser_ws_server();
std::env::set_var("SGCLAW_BROWSER_WS_URL", &ws_url);
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
handle_browser_message_with_context(
transport.as_ref(),
&browser_tool,
&runtime_context,
BrowserMessage::SubmitTask {
instruction: "打开知乎热榜获取前10条数据并导出 Excel".to_string(),
conversation_id: String::new(),
messages: vec![],
page_url: String::new(),
page_title: String::new(),
},
)
.unwrap();
ws_handle.join().unwrap();
let sent = transport.sent_messages();
let websocket_frames = frames.lock().unwrap().clone();
assert_eq!(websocket_frames.len(), 4, "{websocket_frames:?}");
assert_eq!(websocket_frames[0], r#"{"type":"register","role":"web"}"#);
assert!(!websocket_frames
.iter()
.any(|frame| frame.contains("/sgclaw/browser-helper.html")));
assert!(!websocket_frames
.iter()
.any(|frame| frame.contains("\"sgBrowerserOpenPage\"")));
let navigate: Value = serde_json::from_str(&websocket_frames[1]).unwrap();
assert_eq!(navigate[0], json!("https://www.zhihu.com"));
assert_eq!(navigate[1], json!("sgHideBrowserCallAfterLoaded"));
assert_eq!(navigate[2], json!("https://www.zhihu.com/hot"));
let get_text: Value = serde_json::from_str(&websocket_frames[2]).unwrap();
assert_eq!(get_text[0], json!("https://www.zhihu.com/hot"));
assert_eq!(get_text[1], json!("sgBrowserExcuteJsCodeByArea"));
assert_eq!(get_text[2], json!("https://www.zhihu.com/hot"));
let eval: Value = serde_json::from_str(&websocket_frames[3]).unwrap();
assert_eq!(eval[0], json!("https://www.zhihu.com/hot"));
assert_eq!(eval[1], json!("sgBrowserExcuteJsCodeByArea"));
assert_eq!(eval[2], json!("https://www.zhihu.com/hot"));
assert!(sent.iter().any(|message| {
matches!(
message,
AgentMessage::LogEntry { level, message }
if level == "mode" && message == "zeroclaw_process_message_primary"
)
}));
assert!(sent.iter().any(|message| {
matches!(
message,
AgentMessage::TaskComplete { success, summary }
if *success && summary.contains("已导出并打开知乎热榜 Excel") && summary.contains(".xlsx")
)
}));
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
assert!(!sent.iter().any(|message| matches!(message, AgentMessage::Command { .. })));
}
#[test]
fn lifecycle_messages_emit_status_events_without_browser_commands() {
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
sgclaw::agent::handle_browser_message(transport.as_ref(), &browser_tool, BrowserMessage::Connect)
.unwrap();
sgclaw::agent::handle_browser_message(transport.as_ref(), &browser_tool, BrowserMessage::Start)
.unwrap();
sgclaw::agent::handle_browser_message(transport.as_ref(), &browser_tool, BrowserMessage::Stop)
.unwrap();
let sent = transport.sent_messages();
assert_eq!(
sent,
vec![
AgentMessage::StatusChanged {
state: "connected".to_string(),
},
AgentMessage::StatusChanged {
state: "started".to_string(),
},
AgentMessage::StatusChanged {
state: "stopped".to_string(),
},
]
);
assert!(!sent
.iter()
.any(|message| { matches!(message, AgentMessage::Command { .. }) }));
}
#[test]
fn production_submit_task_does_not_route_into_legacy_runtime_without_llm_config() {
std::env::remove_var("DEEPSEEK_API_KEY");
std::env::remove_var("DEEPSEEK_BASE_URL");
std::env::remove_var("DEEPSEEK_MODEL");
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
handle_browser_message(
transport.as_ref(),
&browser_tool,
BrowserMessage::SubmitTask {
instruction: "打开百度".to_string(),
conversation_id: String::new(),
messages: vec![],
page_url: String::new(),
page_title: String::new(),
},
)
.unwrap();
let sent = transport.sent_messages();
assert!(matches!(
sent.last(),
Some(AgentMessage::TaskComplete { success, summary })
if !success && summary.contains("未配置大语言模型")
));
assert!(!sent
.iter()
.any(|message| { matches!(message, AgentMessage::Command { .. }) }));
}