use std::collections::VecDeque; use std::sync::{Arc, Mutex}; use std::time::Duration; use serde_json::{json, Value}; use sgclaw::browser::ws_backend::WsClient; use sgclaw::browser::{BrowserBackend, WsBrowserBackend}; use sgclaw::pipe::{Action, PipeError}; use sgclaw::security::MacPolicy; fn test_policy() -> MacPolicy { MacPolicy::from_json_str( r#"{ "version": "1.0", "domains": { "allowed": ["www.baidu.com"] }, "pipe_actions": { "allowed": ["click", "type", "navigate", "getText", "eval"], "blocked": [] } }"#, ) .unwrap() } struct FakeWsClient { incoming: Mutex>>, sent: Mutex>, } impl FakeWsClient { fn new(frames: Vec>) -> Self { Self { incoming: Mutex::new( frames .into_iter() .map(|frame| frame.map(str::to_string)) .collect(), ), sent: Mutex::new(Vec::new()), } } fn sent_frames(&self) -> Vec { self.sent.lock().unwrap().clone() } } impl WsClient for FakeWsClient { fn send_text(&self, payload: &str) -> Result<(), PipeError> { self.sent.lock().unwrap().push(payload.to_string()); Ok(()) } fn recv_text_timeout(&self, _timeout: Duration) -> Result { self.incoming .lock() .unwrap() .pop_front() .unwrap_or(Err(PipeError::Timeout)) } } #[test] fn ws_backend_ignores_welcome_frame_before_zero_status() { let client = Arc::new(FakeWsClient::new(vec![ Ok("Welcome! You are client #1"), Ok("0"), Ok( r#"["https://www.baidu.com/current","callBackJsToCpp","https://www.baidu.com/current@_@https://www.baidu.com@_@sgclaw_cb_1@_@sgHideBrowserCallAfterLoaded@_@"]"#, ), ])); let backend = WsBrowserBackend::new( client.clone(), test_policy(), "https://www.baidu.com/current", ) .with_response_timeout(Duration::from_secs(1)); let output = backend .invoke( Action::Navigate, json!({ "url": "https://www.baidu.com" }), "www.baidu.com", ) .unwrap(); assert!(output.success); let sent = client.sent_frames(); assert_eq!(sent.len(), 1); } #[test] fn ws_backend_ignores_json_welcome_frame_before_zero_status() { let client = Arc::new(FakeWsClient::new(vec![ Ok(r#"{"type":"welcome","client_id":17,"server_time":"2026-04-04T11:04:54"}"#), Ok("0"), Ok( r#"["https://www.baidu.com/current","callBackJsToCpp","https://www.baidu.com/current@_@https://www.baidu.com@_@sgclaw_cb_1@_@sgHideBrowserCallAfterLoaded@_@"]"#, ), ])); let backend = WsBrowserBackend::new( client.clone(), test_policy(), "https://www.baidu.com/current", ) .with_response_timeout(Duration::from_secs(1)); let output = backend .invoke( Action::Navigate, json!({ "url": "https://www.baidu.com" }), "www.baidu.com", ) .unwrap(); assert!(output.success); let sent = client.sent_frames(); assert_eq!(sent.len(), 1); } #[test] fn ws_backend_fails_on_non_numeric_non_welcome_status_frame() { let client = Arc::new(FakeWsClient::new(vec![Ok("not-a-status") ])); let backend = WsBrowserBackend::new( client, test_policy(), "https://www.baidu.com/current", ) .with_response_timeout(Duration::from_secs(1)); let error = backend .invoke( Action::Click, json!({ "target_url": "https://www.baidu.com/current", "selector": "#submit" }), "www.baidu.com", ) .unwrap_err(); assert!(error.to_string().contains("invalid browser status frame: not-a-status")); } #[test] fn ws_backend_returns_success_for_zero_without_callback() { let client = Arc::new(FakeWsClient::new(vec![ Ok("0"), Ok( r#"["https://www.baidu.com/current","callBackJsToCpp","https://www.baidu.com/current@_@https://www.baidu.com@_@sgclaw_cb_1@_@sgHideBrowserCallAfterLoaded@_@"]"#, ), ])); let backend = WsBrowserBackend::new( client.clone(), test_policy(), "https://www.baidu.com/current", ) .with_response_timeout(Duration::from_secs(1)); let output = backend .invoke( Action::Navigate, json!({ "url": "https://www.baidu.com" }), "www.baidu.com", ) .unwrap(); assert_eq!(output.seq, 1); assert!(output.success); assert_eq!(output.data, json!({ "text": "" })); assert!(output.aom_snapshot.is_empty()); let sent = client.sent_frames(); assert_eq!(sent.len(), 1); let payload: Value = serde_json::from_str(&sent[0]).unwrap(); assert_eq!(payload[1], json!("sgHideBrowserCallAfterLoaded")); assert_eq!(payload[2], json!("https://www.baidu.com")); } #[test] fn ws_backend_fails_immediately_on_non_zero_return_code() { let client = Arc::new(FakeWsClient::new(vec![Ok("7")])); let backend = WsBrowserBackend::new( client, test_policy(), "https://www.baidu.com/current", ) .with_response_timeout(Duration::from_secs(1)); let error = backend .invoke( Action::Click, json!({ "target_url": "https://www.baidu.com/current", "selector": "#submit" }), "www.baidu.com", ) .unwrap_err(); assert!(error.to_string().contains("browser returned non-zero status: 7")); } #[test] fn ws_backend_waits_for_callback_and_normalizes_result_payload() { let client = Arc::new(FakeWsClient::new(vec![ Ok("0"), Ok( r#"["https://www.baidu.com/current","callBackJsToCpp","https://www.baidu.com/current@_@https://www.baidu.com/current@_@sgclaw_cb_1@_@sgBrowserExcuteJsCodeByArea@_@天气"]"#, ), ])); let backend = WsBrowserBackend::new( client.clone(), test_policy(), "https://www.baidu.com/current", ) .with_response_timeout(Duration::from_secs(1)); let output = backend .invoke( Action::GetText, json!({ "target_url": "https://www.baidu.com/current", "selector": "#content" }), "www.baidu.com", ) .unwrap(); assert_eq!(output.seq, 1); assert!(output.success); assert_eq!(output.data, json!({ "text": "天气" })); assert!(output.aom_snapshot.is_empty()); let sent = client.sent_frames(); assert_eq!(sent.len(), 1); let payload: Value = serde_json::from_str(&sent[0]).unwrap(); assert_eq!(payload[1], json!("sgBrowserExcuteJsCodeByArea")); } #[test] fn ws_backend_times_out_while_waiting_for_callback_after_zero_status() { let client = Arc::new(FakeWsClient::new(vec![Ok("0")])); let backend = WsBrowserBackend::new( client, test_policy(), "https://www.baidu.com/current", ) .with_response_timeout(Duration::from_millis(1)); let error = backend .invoke( Action::Eval, json!({ "target_url": "https://www.baidu.com/current", "script": "2 + 2" }), "www.baidu.com", ) .unwrap_err(); assert!(matches!(error, PipeError::Timeout)); } #[test] fn ws_backend_times_out_when_navigate_callback_never_arrives() { let client = Arc::new(FakeWsClient::new(vec![ Err(PipeError::Timeout), Err(PipeError::Timeout), ])); let backend = WsBrowserBackend::new(client.clone(), test_policy(), "https://www.zhihu.com") .with_response_timeout(Duration::from_millis(1)); let error = backend .invoke( Action::Navigate, json!({ "url": "https://www.zhihu.com/hot" }), "www.baidu.com", ) .unwrap_err(); assert!(matches!(error, PipeError::Timeout)); let sent = client.sent_frames(); let payload: Value = serde_json::from_str(&sent[0]).unwrap(); assert_eq!(payload[1], json!("sgHideBrowserCallAfterLoaded")); assert_eq!(payload[2], json!("https://www.zhihu.com/hot")); } #[test] fn ws_backend_reuses_last_navigated_url_for_followup_requests() { let client = Arc::new(FakeWsClient::new(vec![ Ok("0"), Ok( r#"["https://www.baidu.com/current","callBackJsToCpp","https://www.baidu.com/current@_@https://www.baidu.com@_@sgclaw_cb_1@_@sgHideBrowserCallAfterLoaded@_@"]"#, ), Ok("0"), Ok( r#"["https://www.zhihu.com/hot","callBackJsToCpp","https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_2@_@sgBrowserExcuteJsCodeByArea@_@热榜文本"]"#, ), ])); let backend = WsBrowserBackend::new(client.clone(), test_policy(), "about:blank") .with_response_timeout(Duration::from_secs(1)); backend .invoke( Action::Navigate, json!({ "url": "https://www.zhihu.com/hot" }), "www.baidu.com", ) .unwrap(); let output = backend .invoke( Action::GetText, json!({ "selector": "body" }), "www.baidu.com", ) .unwrap(); assert!(output.success); assert_eq!(output.data, json!({ "text": "热榜文本" })); let sent = client.sent_frames(); assert_eq!(sent.len(), 2); let navigate_payload: Value = serde_json::from_str(&sent[0]).unwrap(); assert_eq!(navigate_payload[0], json!("about:blank")); assert_eq!(navigate_payload[1], json!("sgHideBrowserCallAfterLoaded")); assert_eq!(navigate_payload[2], json!("https://www.zhihu.com/hot")); let followup_payload: Value = serde_json::from_str(&sent[1]).unwrap(); assert_eq!(followup_payload[0], json!("https://www.zhihu.com/hot")); assert_eq!(followup_payload[1], json!("sgBrowserExcuteJsCodeByArea")); assert_eq!(followup_payload[2], json!("https://www.zhihu.com/hot")); assert_eq!(followup_payload[4], json!("hide")); } #[test] fn ws_backend_propagates_socket_drop_after_navigate_send() { let client = Arc::new(FakeWsClient::new(vec![Err(PipeError::PipeClosed)])); let backend = WsBrowserBackend::new( client, test_policy(), "https://www.baidu.com/current", ) .with_response_timeout(Duration::from_secs(1)); let error = backend .invoke( Action::Navigate, json!({ "url": "https://www.baidu.com" }), "www.baidu.com", ) .unwrap_err(); assert!(matches!(error, PipeError::PipeClosed)); }