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>
This commit is contained in:
356
tests/browser_ws_backend_test.rs
Normal file
356
tests/browser_ws_backend_test.rs
Normal file
@@ -0,0 +1,356 @@
|
||||
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<VecDeque<Result<String, PipeError>>>,
|
||||
sent: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeWsClient {
|
||||
fn new(frames: Vec<Result<&str, PipeError>>) -> 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<String> {
|
||||
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<String, PipeError> {
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user