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> = 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>>, 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 { .. }) })); }