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:
木炎
2026-04-06 21:44:53 +08:00
parent 0dd655712c
commit bdf8e12246
55 changed files with 14440 additions and 1053 deletions

View File

@@ -0,0 +1,151 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use serde_json::json;
use sgclaw::browser::bridge_contract::{
BridgeBrowserActionError, BridgeBrowserActionReply, BridgeBrowserActionRequest,
BridgeBrowserActionSuccess,
};
use sgclaw::browser::bridge_transport::BridgeActionTransport;
use sgclaw::browser::{BridgeBrowserBackend, BrowserBackend};
use sgclaw::pipe::{Action, PipeError, Timing};
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 FakeBridgeTransport {
requests: Mutex<Vec<BridgeBrowserActionRequest>>,
replies: Mutex<VecDeque<Result<BridgeBrowserActionReply, PipeError>>>,
}
impl FakeBridgeTransport {
fn new(replies: Vec<Result<BridgeBrowserActionReply, PipeError>>) -> Self {
Self {
requests: Mutex::new(Vec::new()),
replies: Mutex::new(replies.into()),
}
}
fn recorded_requests(&self) -> Vec<BridgeBrowserActionRequest> {
self.requests.lock().unwrap().clone()
}
}
impl BridgeActionTransport for FakeBridgeTransport {
fn execute(
&self,
request: BridgeBrowserActionRequest,
) -> Result<BridgeBrowserActionReply, PipeError> {
self.requests.lock().unwrap().push(request);
self.replies
.lock()
.unwrap()
.pop_front()
.unwrap_or(Err(PipeError::Timeout))
}
}
#[test]
fn bridge_backend_maps_navigate_to_bridge_action_request() {
let transport = Arc::new(FakeBridgeTransport::new(vec![Ok(
BridgeBrowserActionReply::Success(BridgeBrowserActionSuccess {
data: json!({ "navigated": true }),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 11,
},
}),
)]));
let backend = BridgeBrowserBackend::new(transport.clone(), test_policy());
let output = backend
.invoke(
Action::Navigate,
json!({ "url": "https://www.baidu.com" }),
"www.baidu.com",
)
.unwrap();
assert_eq!(
transport.recorded_requests(),
vec![BridgeBrowserActionRequest::new(
"navigate",
json!({ "url": "https://www.baidu.com" }),
"www.baidu.com",
)]
);
assert_eq!(output.seq, 1);
assert!(output.success);
}
#[test]
fn bridge_backend_normalizes_successful_bridge_reply() {
let transport = Arc::new(FakeBridgeTransport::new(vec![Ok(
BridgeBrowserActionReply::Success(BridgeBrowserActionSuccess {
data: json!({ "text": "天气" }),
aom_snapshot: vec![json!({ "role": "textbox", "name": "百度一下" })],
timing: Timing {
queue_ms: 4,
exec_ms: 14,
},
}),
)]));
let backend = BridgeBrowserBackend::new(transport, test_policy());
let output = backend
.invoke(
Action::GetText,
json!({ "selector": "#content_left" }),
"www.baidu.com",
)
.unwrap();
assert_eq!(output.seq, 1);
assert!(output.success);
assert_eq!(output.data, json!({ "text": "天气" }));
assert_eq!(
output.aom_snapshot,
vec![json!({ "role": "textbox", "name": "百度一下" })]
);
assert_eq!(
output.timing,
Timing {
queue_ms: 4,
exec_ms: 14,
}
);
}
#[test]
fn bridge_backend_maps_bridge_failure_to_pipe_error() {
let transport = Arc::new(FakeBridgeTransport::new(vec![Ok(
BridgeBrowserActionReply::Error(BridgeBrowserActionError {
message: "selector not found".to_string(),
details: json!({ "selector": "#missing" }),
}),
)]));
let backend = BridgeBrowserBackend::new(transport, test_policy());
let error = backend
.invoke(
Action::Click,
json!({ "selector": "#missing" }),
"www.baidu.com",
)
.unwrap_err();
assert!(matches!(error, PipeError::Protocol(message) if message == "bridge action failed: selector not found"));
}