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:
@@ -1,36 +1,167 @@
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
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 sgclaw::agent::handle_browser_message;
|
||||
use sgclaw::agent::runtime::{browser_action_tool_definition, execute_task_with_provider};
|
||||
use sgclaw::llm::{ChatMessage, LlmError, LlmProvider, ToolDefinition, ToolFunctionCall};
|
||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
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;
|
||||
|
||||
struct FakeProvider {
|
||||
calls: Vec<ToolFunctionCall>,
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
impl LlmProvider for FakeProvider {
|
||||
fn chat(
|
||||
&self,
|
||||
_messages: &[ChatMessage],
|
||||
_tools: &[ToolDefinition],
|
||||
) -> Result<Vec<ToolFunctionCall>, LlmError> {
|
||||
Ok(self.calls.clone())
|
||||
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"] },
|
||||
"domains": { "allowed": ["www.baidu.com", "www.zhihu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText"],
|
||||
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
||||
"blocked": []
|
||||
}
|
||||
}"#,
|
||||
@@ -39,104 +170,131 @@ fn test_policy() -> MacPolicy {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_action_tool_definition_uses_expected_name() {
|
||||
let tool = browser_action_tool_definition();
|
||||
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");
|
||||
|
||||
assert_eq!(tool.name, "browser_action");
|
||||
assert_eq!(tool.parameters["required"][0], "action");
|
||||
assert_eq!(tool.parameters["required"][1], "expected_domain");
|
||||
}
|
||||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||
std::env::remove_var("DEEPSEEK_MODEL");
|
||||
|
||||
#[test]
|
||||
fn runtime_executes_provider_tool_calls_and_returns_summary() {
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: serde_json::json!({ "navigated": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 2,
|
||||
success: true,
|
||||
data: serde_json::json!({ "typed": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
]));
|
||||
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 provider = FakeProvider {
|
||||
calls: vec![
|
||||
ToolFunctionCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "browser_action".to_string(),
|
||||
arguments: serde_json::json!({
|
||||
"action": "navigate",
|
||||
"expected_domain": "www.baidu.com",
|
||||
"url": "https://www.baidu.com"
|
||||
}),
|
||||
},
|
||||
ToolFunctionCall {
|
||||
id: "call-2".to_string(),
|
||||
name: "browser_action".to_string(),
|
||||
arguments: serde_json::json!({
|
||||
"action": "type",
|
||||
"expected_domain": "www.baidu.com",
|
||||
"selector": "#kw",
|
||||
"text": "天气",
|
||||
"clear_first": true
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let summary = execute_task_with_provider(
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&provider,
|
||||
"打开百度搜索天气",
|
||||
&runtime_context,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "打开知乎热榜,获取前10条数据,并导出 Excel".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: String::new(),
|
||||
page_title: String::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(summary, "已通过 Agent 执行任务: 打开百度搜索天气");
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "navigate www.baidu.com"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 1 && action == &Action::Navigate
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[2],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "type www.baidu.com"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[3],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 2 && action == &Action::Type
|
||||
));
|
||||
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 legacy_agent_runtime_is_explicitly_dev_only() {
|
||||
assert!(sgclaw::agent::runtime::LEGACY_DEV_ONLY);
|
||||
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]
|
||||
|
||||
145
tests/browser_backend_capability_test.rs
Normal file
145
tests/browser_backend_capability_test.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use common::MockTransport;
|
||||
use sgclaw::browser::{BrowserBackend, PipeBrowserBackend};
|
||||
use sgclaw::compat::browser_script_skill_tool::build_browser_script_skill_tools;
|
||||
use sgclaw::pipe::{Action, CommandOutput, ExecutionSurfaceKind, ExecutionSurfaceMetadata};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use zeroclaw::skills::{Skill, SkillTool};
|
||||
|
||||
fn backend_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["oa.example.com", "erp.example.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText"],
|
||||
"blocked": ["eval", "executeJsInPage"]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn eval_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.zhihu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
||||
"blocked": []
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipe_browser_backend_keeps_privileged_pipe_surface_metadata() {
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let backend = PipeBrowserBackend::new(transport, backend_policy(), vec![1, 2, 3, 4]);
|
||||
|
||||
let metadata = backend.surface_metadata();
|
||||
|
||||
assert_eq!(metadata.kind, ExecutionSurfaceKind::PrivilegedBrowserPipe);
|
||||
assert!(metadata.privileged);
|
||||
assert!(!metadata.defines_runtime_identity);
|
||||
assert_eq!(metadata.guard, "mac_policy");
|
||||
assert_eq!(
|
||||
metadata.allowed_domains,
|
||||
vec!["oa.example.com", "erp.example.com"]
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.allowed_actions,
|
||||
vec!["click", "type", "navigate", "getText"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipe_browser_backend_reports_eval_capability_from_mac_policy() {
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let backend = PipeBrowserBackend::new(transport, eval_policy(), vec![1, 2, 3, 4]);
|
||||
|
||||
assert!(backend.supports_eval());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_script_tools_are_hidden_when_backend_cannot_eval() {
|
||||
let skill_root = unique_temp_dir("sgclaw-browser-backend-capability");
|
||||
let scripts_dir = skill_root.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(
|
||||
scripts_dir.join("extract_hotlist.js"),
|
||||
"return { rows: [[1, '标题', '10万热度']] };",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = vec![Skill {
|
||||
name: "zhihu-hotlist".to_string(),
|
||||
description: "Zhihu hotlist helpers".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
author: None,
|
||||
tags: vec![],
|
||||
tools: vec![SkillTool {
|
||||
name: "extract_hotlist".to_string(),
|
||||
description: "Extract structured hotlist rows".to_string(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: "scripts/extract_hotlist.js".to_string(),
|
||||
args: HashMap::new(),
|
||||
}],
|
||||
prompts: vec![],
|
||||
location: Some(skill_root.join("skill.json")),
|
||||
}];
|
||||
let backend: Arc<dyn BrowserBackend> = Arc::new(FakeBrowserBackend::new(false));
|
||||
|
||||
let tools = build_browser_script_skill_tools(&skills, backend).unwrap();
|
||||
|
||||
assert!(tools.is_empty());
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeBrowserBackend {
|
||||
supports_eval: bool,
|
||||
}
|
||||
|
||||
impl FakeBrowserBackend {
|
||||
fn new(supports_eval: bool) -> Self {
|
||||
Self { supports_eval }
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserBackend for FakeBrowserBackend {
|
||||
fn invoke(
|
||||
&self,
|
||||
_action: Action,
|
||||
_params: serde_json::Value,
|
||||
_expected_domain: &str,
|
||||
) -> Result<CommandOutput, sgclaw::pipe::PipeError> {
|
||||
panic!("invoke should not be called in this capability-gating test")
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
ExecutionSurfaceMetadata::privileged_browser_pipe("fake_backend")
|
||||
}
|
||||
|
||||
fn supports_eval(&self) -> bool {
|
||||
self.supports_eval
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("{prefix}-{nanos}"));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
151
tests/browser_bridge_backend_test.rs
Normal file
151
tests/browser_bridge_backend_test.rs
Normal 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"));
|
||||
}
|
||||
80
tests/browser_bridge_contract_test.rs
Normal file
80
tests/browser_bridge_contract_test.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use serde_json::{json, Value};
|
||||
use sgclaw::browser::bridge_contract::{BridgeBrowserActionRequest, BridgeLifecycleCall};
|
||||
|
||||
#[test]
|
||||
fn bridge_contract_names_match_documented_bridge_surface() {
|
||||
let lifecycle_names = [
|
||||
BridgeLifecycleCall::Connect.bridge_name(),
|
||||
BridgeLifecycleCall::Start.bridge_name(),
|
||||
BridgeLifecycleCall::Stop.bridge_name(),
|
||||
BridgeLifecycleCall::SubmitTask.bridge_name(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
lifecycle_names,
|
||||
[
|
||||
"sgclawConnect",
|
||||
"sgclawStart",
|
||||
"sgclawStop",
|
||||
"sgclawSubmitTask",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_contract_represents_browser_action_requests_without_ws_business_frames() {
|
||||
let requests = vec![
|
||||
BridgeBrowserActionRequest::new(
|
||||
"navigate",
|
||||
json!({ "url": "https://www.baidu.com" }),
|
||||
"www.baidu.com",
|
||||
),
|
||||
BridgeBrowserActionRequest::new(
|
||||
"click",
|
||||
json!({ "selector": "#submit" }),
|
||||
"www.zhihu.com",
|
||||
),
|
||||
BridgeBrowserActionRequest::new(
|
||||
"getText",
|
||||
json!({ "selector": "#content" }),
|
||||
"www.zhihu.com",
|
||||
),
|
||||
];
|
||||
|
||||
let serialized = serde_json::to_value(&requests).unwrap();
|
||||
let entries = serialized.as_array().unwrap();
|
||||
let actions = entries
|
||||
.iter()
|
||||
.map(|entry| entry["action"].as_str().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
serialized,
|
||||
json!([
|
||||
{
|
||||
"action": "navigate",
|
||||
"params": { "url": "https://www.baidu.com" },
|
||||
"expected_domain": "www.baidu.com"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"params": { "selector": "#submit" },
|
||||
"expected_domain": "www.zhihu.com"
|
||||
},
|
||||
{
|
||||
"action": "getText",
|
||||
"params": { "selector": "#content" },
|
||||
"expected_domain": "www.zhihu.com"
|
||||
}
|
||||
])
|
||||
);
|
||||
assert_eq!(actions, vec!["navigate", "click", "getText"]);
|
||||
|
||||
let first = entries.first().unwrap();
|
||||
let object = first.as_object().unwrap();
|
||||
assert_eq!(object.len(), 3);
|
||||
assert!(object.contains_key("action"));
|
||||
assert!(object.contains_key("params"));
|
||||
assert!(object.contains_key("expected_domain"));
|
||||
assert_eq!(first["expected_domain"], Value::String("www.baidu.com".to_string()));
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use common::MockTransport;
|
||||
use serde_json::json;
|
||||
use sgclaw::browser::{BrowserBackend, PipeBrowserBackend};
|
||||
use sgclaw::compat::browser_script_skill_tool::BrowserScriptSkillTool;
|
||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
use sgclaw::security::MacPolicy;
|
||||
@@ -67,6 +68,7 @@ return {
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let backend: Arc<dyn BrowserBackend> = Arc::new(PipeBrowserBackend::from_inner(browser_tool));
|
||||
|
||||
let mut args = HashMap::new();
|
||||
args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
||||
@@ -77,7 +79,7 @@ return {
|
||||
command: "scripts/extract_hotlist.js".to_string(),
|
||||
args,
|
||||
};
|
||||
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_dir, browser_tool)
|
||||
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_dir, backend)
|
||||
.unwrap();
|
||||
|
||||
let result = tool
|
||||
|
||||
@@ -106,6 +106,53 @@ fn browser_tool_exposes_privileged_surface_metadata_backed_by_mac_policy() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_tool_accepts_approved_local_dashboard_navigate_request() {
|
||||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: serde_json::json!({"navigated": true}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 20,
|
||||
},
|
||||
}]));
|
||||
let tool = BrowserPipeTool::new(transport.clone(), test_policy(), vec![1, 2, 3, 4])
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let result = tool
|
||||
.invoke(
|
||||
Action::Navigate,
|
||||
serde_json::json!({
|
||||
"url": "file:///C:/tmp/zhihu-hotlist-screen.html",
|
||||
"sgclaw_local_dashboard_open": {
|
||||
"source": "compat.workflow_executor",
|
||||
"kind": "zhihu_hotlist_screen",
|
||||
"output_path": "C:/tmp/zhihu-hotlist-screen.html",
|
||||
"presentation_url": "file:///C:/tmp/zhihu-hotlist-screen.html"
|
||||
}
|
||||
}),
|
||||
"__sgclaw_local_dashboard__",
|
||||
)
|
||||
.unwrap();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert!(result.success);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::Command {
|
||||
action,
|
||||
params,
|
||||
security,
|
||||
..
|
||||
} if action == &Action::Navigate
|
||||
&& security.expected_domain == "__sgclaw_local_dashboard__"
|
||||
&& params["url"] == serde_json::json!("file:///C:/tmp/zhihu-hotlist-screen.html")
|
||||
&& params["sgclaw_local_dashboard_open"]["kind"] == serde_json::json!("zhihu_hotlist_screen")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_rules_allow_zhihu_navigation() {
|
||||
let rules_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -115,3 +162,22 @@ fn default_rules_allow_zhihu_navigation() {
|
||||
|
||||
policy.validate(&Action::Navigate, "www.zhihu.com").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_policy_rejects_non_html_local_dashboard_presentation() {
|
||||
let rules_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources")
|
||||
.join("rules.json");
|
||||
let policy = MacPolicy::load_from_path(rules_path).unwrap();
|
||||
|
||||
let err = policy
|
||||
.validate_local_dashboard_presentation(
|
||||
&Action::Navigate,
|
||||
"__sgclaw_local_dashboard__",
|
||||
"file:///C:/tmp/zhihu-hotlist-screen.txt",
|
||||
"C:/tmp/zhihu-hotlist-screen.txt",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("local dashboard"));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
422
tests/browser_ws_probe_test.rs
Normal file
422
tests/browser_ws_probe_test.rs
Normal file
@@ -0,0 +1,422 @@
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use tungstenite::{accept, Message};
|
||||
|
||||
#[path = "../src/browser/ws_probe.rs"]
|
||||
mod ws_probe;
|
||||
|
||||
use ws_probe::{
|
||||
parse_probe_args, run_probe_script, ProbeCliConfig, ProbeOutcome, ProbeStep, ProbeStepResult,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
enum ServerStep {
|
||||
ReceiveThenReply { expected: String, reply: String },
|
||||
ReceiveThenReplyFrames { expected: String, replies: Vec<String> },
|
||||
ReceiveThenStaySilent { expected: String },
|
||||
ReceiveThenClose { expected: String },
|
||||
CloseBeforeReceive,
|
||||
}
|
||||
|
||||
fn spawn_fake_server(script: Vec<ServerStep>) -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let received = Arc::new(Mutex::new(Vec::new()));
|
||||
let received_for_thread = received.clone();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut socket = accept(stream).unwrap();
|
||||
|
||||
for step in script {
|
||||
match step {
|
||||
ServerStep::CloseBeforeReceive => {
|
||||
socket.close(None).unwrap();
|
||||
return;
|
||||
}
|
||||
ServerStep::ReceiveThenReply { expected, reply } => {
|
||||
let message = socket.read().unwrap();
|
||||
let payload = match message {
|
||||
Message::Text(text) => text.to_string(),
|
||||
other => panic!("expected text frame, got {other:?}"),
|
||||
};
|
||||
received_for_thread.lock().unwrap().push(payload.clone());
|
||||
assert_eq!(payload, expected);
|
||||
socket.send(Message::Text(reply.into())).unwrap();
|
||||
}
|
||||
ServerStep::ReceiveThenReplyFrames { expected, replies } => {
|
||||
let message = socket.read().unwrap();
|
||||
let payload = match message {
|
||||
Message::Text(text) => text.to_string(),
|
||||
other => panic!("expected text frame, got {other:?}"),
|
||||
};
|
||||
received_for_thread.lock().unwrap().push(payload.clone());
|
||||
assert_eq!(payload, expected);
|
||||
for reply in replies {
|
||||
socket.send(Message::Text(reply.into())).unwrap();
|
||||
}
|
||||
}
|
||||
ServerStep::ReceiveThenStaySilent { expected } => {
|
||||
let message = socket.read().unwrap();
|
||||
let payload = match message {
|
||||
Message::Text(text) => text.to_string(),
|
||||
other => panic!("expected text frame, got {other:?}"),
|
||||
};
|
||||
received_for_thread.lock().unwrap().push(payload.clone());
|
||||
assert_eq!(payload, expected);
|
||||
thread::sleep(Duration::from_millis(120));
|
||||
}
|
||||
ServerStep::ReceiveThenClose { expected } => {
|
||||
let message = socket.read().unwrap();
|
||||
let payload = match message {
|
||||
Message::Text(text) => text.to_string(),
|
||||
other => panic!("expected text frame, got {other:?}"),
|
||||
};
|
||||
received_for_thread.lock().unwrap().push(payload.clone());
|
||||
assert_eq!(payload, expected);
|
||||
socket.close(None).unwrap();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(format!("ws://{addr}"), received, handle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_probe_args_rejects_non_ws_schemes() {
|
||||
let cases = [
|
||||
"wss://127.0.0.1:12345",
|
||||
"http://127.0.0.1:12345",
|
||||
"127.0.0.1:12345",
|
||||
];
|
||||
|
||||
for ws_url in cases {
|
||||
let args = vec![
|
||||
"--ws-url".to_string(),
|
||||
ws_url.to_string(),
|
||||
"--timeout-ms".to_string(),
|
||||
"1500".to_string(),
|
||||
"--step".to_string(),
|
||||
"open-agent::[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
||||
];
|
||||
|
||||
let err = parse_probe_args(&args).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!(
|
||||
"probe argument error: unsupported --ws-url scheme (only ws:// is supported for this probe): {ws_url}"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_probe_args_accepts_ws_url_timeout_and_ordered_steps() {
|
||||
let args = vec![
|
||||
"--ws-url".to_string(),
|
||||
"ws://127.0.0.1:12345".to_string(),
|
||||
"--timeout-ms".to_string(),
|
||||
"1500".to_string(),
|
||||
"--step".to_string(),
|
||||
"open-agent::[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
||||
"--step".to_string(),
|
||||
"open-hot::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
let parsed = parse_probe_args(&args).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
ProbeCliConfig {
|
||||
ws_url: "ws://127.0.0.1:12345".to_string(),
|
||||
timeout_ms: 1500,
|
||||
steps: vec![
|
||||
ProbeStep {
|
||||
label: "open-agent".to_string(),
|
||||
payload: "[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
ProbeStep {
|
||||
label: "open-hot".to_string(),
|
||||
payload:
|
||||
"[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
|
||||
.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_probe_args_defaults_register_step_when_step_is_omitted() {
|
||||
let args = vec![
|
||||
"--ws-url".to_string(),
|
||||
"ws://127.0.0.1:12345".to_string(),
|
||||
];
|
||||
|
||||
let parsed = parse_probe_args(&args).unwrap();
|
||||
|
||||
assert_eq!(parsed.ws_url, "ws://127.0.0.1:12345");
|
||||
assert_eq!(parsed.timeout_ms, 1500);
|
||||
assert_eq!(
|
||||
parsed.steps,
|
||||
vec![ProbeStep {
|
||||
label: "register".to_string(),
|
||||
payload: r#"{"type":"register","role":"web"}"#.to_string(),
|
||||
expect_reply: true,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_probe_args_defaults_timeout_when_flag_is_omitted() {
|
||||
let args = vec![
|
||||
"--ws-url".to_string(),
|
||||
"ws://127.0.0.1:12345".to_string(),
|
||||
"--step".to_string(),
|
||||
"open-agent::[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
||||
];
|
||||
|
||||
let parsed = parse_probe_args(&args).unwrap();
|
||||
|
||||
assert_eq!(parsed.ws_url, "ws://127.0.0.1:12345");
|
||||
assert_eq!(parsed.timeout_ms, 1500);
|
||||
assert_eq!(
|
||||
parsed.steps,
|
||||
vec![ProbeStep {
|
||||
label: "open-agent".to_string(),
|
||||
payload: "[\"about:blank\",\"sgOpenAgent\"]".to_string(),
|
||||
expect_reply: true,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_records_welcome_then_silence_transcript() {
|
||||
let steps = vec![
|
||||
ProbeStep {
|
||||
label: "open-agent".to_string(),
|
||||
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
ProbeStep {
|
||||
label: "await-followup".to_string(),
|
||||
payload: r#"["about:blank","sgNoop"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
];
|
||||
let (ws_url, received, handle) = spawn_fake_server(vec![
|
||||
ServerStep::ReceiveThenReply {
|
||||
expected: steps[0].payload.clone(),
|
||||
reply: "Welcome! You are client #1".to_string(),
|
||||
},
|
||||
ServerStep::ReceiveThenStaySilent {
|
||||
expected: steps[1].payload.clone(),
|
||||
},
|
||||
]);
|
||||
|
||||
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
received.lock().unwrap().clone(),
|
||||
steps.iter().map(|step| step.payload.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
results,
|
||||
vec![
|
||||
ProbeStepResult {
|
||||
label: "open-agent".to_string(),
|
||||
sent: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
outcome: ProbeOutcome::Received(vec!["Welcome! You are client #1".to_string()]),
|
||||
},
|
||||
ProbeStepResult {
|
||||
label: "await-followup".to_string(),
|
||||
sent: r#"["about:blank","sgNoop"]"#.to_string(),
|
||||
outcome: ProbeOutcome::TimedOut,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_runs_ordered_frame_script_and_records_per_step_results() {
|
||||
let steps = vec![
|
||||
ProbeStep {
|
||||
label: "bootstrap-1".to_string(),
|
||||
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
ProbeStep {
|
||||
label: "bootstrap-2".to_string(),
|
||||
payload: r#"["about:blank","sgSetAuthInfo","probe-user","probe-token"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
ProbeStep {
|
||||
label: "action".to_string(),
|
||||
payload: r#"["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
];
|
||||
let (ws_url, received, handle) = spawn_fake_server(vec![
|
||||
ServerStep::ReceiveThenReply {
|
||||
expected: steps[0].payload.clone(),
|
||||
reply: "welcome".to_string(),
|
||||
},
|
||||
ServerStep::ReceiveThenReply {
|
||||
expected: steps[1].payload.clone(),
|
||||
reply: "0".to_string(),
|
||||
},
|
||||
ServerStep::ReceiveThenStaySilent {
|
||||
expected: steps[2].payload.clone(),
|
||||
},
|
||||
]);
|
||||
|
||||
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
received.lock().unwrap().clone(),
|
||||
steps.iter().map(|step| step.payload.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0].label, "bootstrap-1");
|
||||
assert_eq!(results[0].outcome, ProbeOutcome::Received(vec!["welcome".to_string()]));
|
||||
assert_eq!(results[1].label, "bootstrap-2");
|
||||
assert_eq!(results[1].outcome, ProbeOutcome::Received(vec!["0".to_string()]));
|
||||
assert_eq!(results[2].label, "action");
|
||||
assert_eq!(results[2].sent, r#"["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]"#);
|
||||
assert_eq!(results[2].outcome, ProbeOutcome::TimedOut);
|
||||
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_records_multiple_frames_for_one_step_within_timeout_window() {
|
||||
let steps = vec![ProbeStep {
|
||||
label: "bootstrap".to_string(),
|
||||
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
}];
|
||||
let (ws_url, received, handle) = spawn_fake_server(vec![ServerStep::ReceiveThenReplyFrames {
|
||||
expected: steps[0].payload.clone(),
|
||||
replies: vec!["welcome".to_string(), "status:ready".to_string()],
|
||||
}]);
|
||||
|
||||
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
|
||||
|
||||
assert_eq!(received.lock().unwrap().as_slice(), [steps[0].payload.as_str()]);
|
||||
assert_eq!(
|
||||
results,
|
||||
vec![ProbeStepResult {
|
||||
label: "bootstrap".to_string(),
|
||||
sent: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
outcome: ProbeOutcome::Received(vec![
|
||||
"welcome".to_string(),
|
||||
"status:ready".to_string(),
|
||||
]),
|
||||
}]
|
||||
);
|
||||
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_records_steps_that_do_not_wait_for_reply_without_ambiguity() {
|
||||
let steps = vec![ProbeStep {
|
||||
label: "fire-and-forget".to_string(),
|
||||
payload: r#"["about:blank","sgNoop"]"#.to_string(),
|
||||
expect_reply: false,
|
||||
}];
|
||||
let (ws_url, received, handle) =
|
||||
spawn_fake_server(vec![ServerStep::ReceiveThenStaySilent {
|
||||
expected: steps[0].payload.clone(),
|
||||
}]);
|
||||
|
||||
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
assert_eq!(received.lock().unwrap().as_slice(), [steps[0].payload.as_str()]);
|
||||
assert_eq!(
|
||||
results,
|
||||
vec![ProbeStepResult {
|
||||
label: "fire-and-forget".to_string(),
|
||||
sent: r#"["about:blank","sgNoop"]"#.to_string(),
|
||||
outcome: ProbeOutcome::NoReplyExpected,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_records_close_when_server_closes_before_next_send() {
|
||||
let steps = vec![
|
||||
ProbeStep {
|
||||
label: "open-agent".to_string(),
|
||||
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
ProbeStep {
|
||||
label: "follow-up".to_string(),
|
||||
payload: r#"["about:blank","sgNoop"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
},
|
||||
];
|
||||
let (ws_url, received, handle) = spawn_fake_server(vec![
|
||||
ServerStep::ReceiveThenReply {
|
||||
expected: steps[0].payload.clone(),
|
||||
reply: "welcome".to_string(),
|
||||
},
|
||||
ServerStep::CloseBeforeReceive,
|
||||
]);
|
||||
|
||||
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
|
||||
|
||||
assert_eq!(received.lock().unwrap().as_slice(), [steps[0].payload.as_str()]);
|
||||
assert_eq!(
|
||||
results,
|
||||
vec![
|
||||
ProbeStepResult {
|
||||
label: "open-agent".to_string(),
|
||||
sent: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
outcome: ProbeOutcome::Received(vec!["welcome".to_string()]),
|
||||
},
|
||||
ProbeStepResult {
|
||||
label: "follow-up".to_string(),
|
||||
sent: r#"["about:blank","sgNoop"]"#.to_string(),
|
||||
outcome: ProbeOutcome::Closed,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_socket_close_separately_from_timeout() {
|
||||
let step = ProbeStep {
|
||||
label: "close-case".to_string(),
|
||||
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
|
||||
expect_reply: true,
|
||||
};
|
||||
let (ws_url, received, handle) = spawn_fake_server(vec![ServerStep::ReceiveThenClose {
|
||||
expected: step.payload.clone(),
|
||||
}]);
|
||||
|
||||
let results = run_probe_script(&ws_url, Duration::from_millis(40), vec![step]).unwrap();
|
||||
|
||||
assert_eq!(received.lock().unwrap().as_slice(), [r#"["about:blank","sgOpenAgent"]"#]);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].label, "close-case");
|
||||
assert_eq!(results[0].outcome, ProbeOutcome::Closed);
|
||||
|
||||
handle.join().unwrap();
|
||||
}
|
||||
195
tests/browser_ws_protocol_test.rs
Normal file
195
tests/browser_ws_protocol_test.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use serde_json::{json, Value};
|
||||
use sgclaw::browser::ws_protocol::{decode_callback_frame, encode_v1_action};
|
||||
use sgclaw::pipe::Action;
|
||||
|
||||
#[test]
|
||||
fn encodes_navigate_frame_exactly_as_browser_array() {
|
||||
let request = encode_v1_action(
|
||||
&Action::Navigate,
|
||||
&json!({ "url": "https://www.baidu.com" }),
|
||||
"https://www.zhihu.com/hot",
|
||||
Some("req42"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
request.payload,
|
||||
r#"["https://www.zhihu.com/hot","sgHideBrowserCallAfterLoaded","https://www.baidu.com","callBackJsToCpp(\"https://www.zhihu.com/hot@_@https://www.baidu.com@_@sgclaw_cb_req42@_@sgHideBrowserCallAfterLoaded@_@\")"]"#
|
||||
);
|
||||
let callback = request.callback.unwrap();
|
||||
assert_eq!(callback.request_id, "req42");
|
||||
assert_eq!(callback.callback_name, "sgclaw_cb_req42");
|
||||
assert_eq!(callback.source_url, "https://www.zhihu.com/hot");
|
||||
assert_eq!(callback.target_url, "https://www.baidu.com");
|
||||
assert_eq!(callback.action_url, "sgHideBrowserCallAfterLoaded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_get_text_frame_with_documented_callback_action_url() {
|
||||
let request = encode_v1_action(
|
||||
&Action::GetText,
|
||||
&json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"selector": "#content"
|
||||
}),
|
||||
"https://www.zhihu.com/hot",
|
||||
Some("req42"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let payload: Value = serde_json::from_str(&request.payload).unwrap();
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!([
|
||||
"https://www.zhihu.com/hot",
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
"https://www.zhihu.com/hot",
|
||||
"(function(){const el=document.querySelector(\"#content\");if(!el){throw new Error(\"selector not found: #content\");}const text=el.innerText ?? el.textContent ?? \"\";callBackJsToCpp(\"https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req42@_@sgBrowserExcuteJsCodeByArea@_@\"+String(text));})();",
|
||||
"hide"
|
||||
])
|
||||
);
|
||||
let callback = request.callback.unwrap();
|
||||
assert_eq!(callback.request_id, "req42");
|
||||
assert_eq!(callback.callback_name, "sgclaw_cb_req42");
|
||||
assert_eq!(callback.source_url, "https://www.zhihu.com/hot");
|
||||
assert_eq!(callback.target_url, "https://www.zhihu.com/hot");
|
||||
assert_eq!(callback.action_url, "sgBrowserExcuteJsCodeByArea");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_callback_payload_from_browser_frame() {
|
||||
let callback = decode_callback_frame(
|
||||
r#"["https://www.zhihu.com/hot","callBackJsToCpp","https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req42@_@sgBrowserExcuteJsCodeByArea@_@天气"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(callback.source_url, "https://www.zhihu.com/hot");
|
||||
assert_eq!(callback.target_url, "https://www.zhihu.com/hot");
|
||||
assert_eq!(callback.callback_name, "sgclaw_cb_req42");
|
||||
assert_eq!(callback.action_url, "sgBrowserExcuteJsCodeByArea");
|
||||
assert_eq!(callback.response_text, "天气");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_callback_frames_and_missing_request_ids() {
|
||||
let malformed = decode_callback_frame(
|
||||
r#"["https://www.zhihu.com/hot","callBackJsToCpp","https://www.zhihu.com/hot@_@too-short"]"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(malformed.to_string().contains("malformed callback payload"));
|
||||
|
||||
let wrong_function = decode_callback_frame(
|
||||
r#"["https://www.zhihu.com/hot","sgBrowerserOpenPage","0"]"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(wrong_function
|
||||
.to_string()
|
||||
.contains("callback frame must target callBackJsToCpp"));
|
||||
|
||||
let missing_request_id = encode_v1_action(
|
||||
&Action::Eval,
|
||||
&json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"script": "2 + 2"
|
||||
}),
|
||||
"https://www.zhihu.com/hot",
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(missing_request_id
|
||||
.to_string()
|
||||
.contains("request_id is required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_uses_documented_js_opcode_for_callback_action_url() {
|
||||
let request = encode_v1_action(
|
||||
&Action::Eval,
|
||||
&json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"script": "2 + 2"
|
||||
}),
|
||||
"https://www.zhihu.com/hot",
|
||||
Some("req-eval"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let callback = request.callback.unwrap();
|
||||
assert_eq!(callback.callback_name, "sgclaw_cb_req-eval");
|
||||
assert_eq!(callback.action_url, "sgBrowserExcuteJsCodeByArea");
|
||||
|
||||
let payload: Value = serde_json::from_str(&request.payload).unwrap();
|
||||
let js = payload[3].as_str().unwrap();
|
||||
assert!(js.contains("callBackJsToCpp(\"https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req-eval@_@sgBrowserExcuteJsCodeByArea@_@\"+String(result))"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covers_supported_v1_action_mapping_and_rejects_unsupported_actions() {
|
||||
let cases = vec![
|
||||
(
|
||||
Action::Navigate,
|
||||
json!({ "url": "https://www.baidu.com" }),
|
||||
Some("req-nav"),
|
||||
"sgHideBrowserCallAfterLoaded",
|
||||
true,
|
||||
),
|
||||
(
|
||||
Action::Click,
|
||||
json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"selector": "#submit"
|
||||
}),
|
||||
None,
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
false,
|
||||
),
|
||||
(
|
||||
Action::Type,
|
||||
json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"selector": "#kw",
|
||||
"text": "天气"
|
||||
}),
|
||||
None,
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
false,
|
||||
),
|
||||
(
|
||||
Action::GetText,
|
||||
json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"selector": "#content"
|
||||
}),
|
||||
Some("req-get-text"),
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
true,
|
||||
),
|
||||
(
|
||||
Action::Eval,
|
||||
json!({
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"script": "2 + 2"
|
||||
}),
|
||||
Some("req-eval"),
|
||||
"sgBrowserExcuteJsCodeByArea",
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
for (action, params, request_id, browser_function, expects_callback) in cases {
|
||||
let request = encode_v1_action(&action, ¶ms, "https://www.zhihu.com/hot", request_id)
|
||||
.unwrap();
|
||||
let payload: Value = serde_json::from_str(&request.payload).unwrap();
|
||||
assert_eq!(payload[1], json!(browser_function), "action={action:?}");
|
||||
assert_eq!(request.callback.is_some(), expects_callback, "action={action:?}");
|
||||
}
|
||||
|
||||
let unsupported = encode_v1_action(
|
||||
&Action::GetHtml,
|
||||
&json!({ "selector": "body" }),
|
||||
"https://www.zhihu.com/hot",
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(unsupported.to_string().contains("unsupported browser ws action"));
|
||||
}
|
||||
@@ -17,6 +17,7 @@ impl MockTransport {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn sent_messages(&self) -> Vec<AgentMessage> {
|
||||
self.sent.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use common::MockTransport;
|
||||
use serde_json::{json, Value};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use sgclaw::{
|
||||
browser::{BrowserBackend, PipeBrowserBackend},
|
||||
compat::browser_tool_adapter::ZeroClawBrowserTool,
|
||||
pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, ExecutionSurfaceKind, Timing},
|
||||
};
|
||||
@@ -28,7 +29,7 @@ fn test_policy() -> MacPolicy {
|
||||
|
||||
fn build_adapter(
|
||||
messages: Vec<BrowserMessage>,
|
||||
) -> (Arc<MockTransport>, ZeroClawBrowserTool<MockTransport>) {
|
||||
) -> (Arc<MockTransport>, ZeroClawBrowserTool) {
|
||||
let transport = Arc::new(MockTransport::new(messages));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
@@ -36,8 +37,9 @@ fn build_adapter(
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let backend: Arc<dyn BrowserBackend> = Arc::new(PipeBrowserBackend::from_inner(browser_tool));
|
||||
|
||||
(transport, ZeroClawBrowserTool::new(browser_tool))
|
||||
(transport, ZeroClawBrowserTool::new(backend))
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -191,6 +191,60 @@ fn sgclaw_settings_load_new_runtime_fields_from_browser_config() {
|
||||
assert_eq!(config.skills.prompt_injection_mode, SkillsPromptMode::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgclaw_settings_load_browser_ws_url_from_browser_config() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-browser-ws-config-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"apiKey": "sk-runtime",
|
||||
"baseUrl": "https://api.deepseek.com",
|
||||
"model": "deepseek-chat",
|
||||
"browserWsUrl": "ws://127.0.0.1:12345"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = SgClawSettings::load(Some(config_path.as_path()))
|
||||
.unwrap()
|
||||
.expect("expected sgclaw settings from config file");
|
||||
|
||||
assert_eq!(
|
||||
settings.browser_ws_url.as_deref(),
|
||||
Some("ws://127.0.0.1:12345")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgclaw_settings_load_service_ws_listen_addr_from_browser_config() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-service-ws-config-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"apiKey": "sk-runtime",
|
||||
"baseUrl": "https://api.deepseek.com",
|
||||
"model": "deepseek-chat",
|
||||
"serviceWsListenAddr": "127.0.0.1:42321"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = SgClawSettings::load(Some(config_path.as_path()))
|
||||
.unwrap()
|
||||
.expect("expected sgclaw settings from config file");
|
||||
|
||||
assert_eq!(
|
||||
settings.service_ws_listen_addr.as_deref(),
|
||||
Some("127.0.0.1:42321")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_attached_config_uses_low_temperature_for_deterministic_execution() {
|
||||
let settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||
|
||||
@@ -33,7 +33,8 @@ async fn openxml_office_tool_renders_hotlist_xlsx_from_rows() {
|
||||
|
||||
assert!(result.success, "{result:?}");
|
||||
assert!(output_path.exists());
|
||||
assert!(result.output.contains(output_path.to_str().unwrap()));
|
||||
let payload: serde_json::Value = serde_json::from_str(&result.output).unwrap();
|
||||
assert_eq!(payload["output_path"], json!(output_path.to_str().unwrap()));
|
||||
|
||||
let unzip = ProcessCommand::new("unzip")
|
||||
.args([
|
||||
|
||||
@@ -13,6 +13,7 @@ use serde_json::{json, Value};
|
||||
use sgclaw::agent::{
|
||||
handle_browser_message, handle_browser_message_with_context, AgentRuntimeContext,
|
||||
};
|
||||
use sgclaw::compat::workflow_executor::finalize_screen_export;
|
||||
use sgclaw::compat::runtime::{execute_task, execute_task_with_sgclaw_settings, CompatTaskContext};
|
||||
use sgclaw::config::{DeepSeekSettings, SgClawSettings};
|
||||
use sgclaw::pipe::{
|
||||
@@ -176,6 +177,7 @@ fn start_fake_deepseek_server(
|
||||
Err(err) => panic!("failed to accept provider request: {err}"),
|
||||
}
|
||||
};
|
||||
stream.set_nonblocking(false).unwrap();
|
||||
let body = read_http_json_body(&mut stream);
|
||||
request_log.lock().unwrap().push(body);
|
||||
|
||||
@@ -1861,6 +1863,15 @@ fn handle_browser_message_exposes_real_zhihu_skill_lib_to_provider_request() {
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
let first_request = request_bodies[0].to_string();
|
||||
let tool_names = request_tool_names(&request_bodies[0]);
|
||||
let loaded_skills_message = sent
|
||||
.iter()
|
||||
.find_map(|message| match message {
|
||||
AgentMessage::LogEntry { level, message } if level == "info" && message.starts_with("loaded skills: ") => {
|
||||
Some(message.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("expected loaded skills log entry");
|
||||
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
@@ -1869,15 +1880,11 @@ fn handle_browser_message_exposes_real_zhihu_skill_lib_to_provider_request() {
|
||||
if *success && summary == "已看到真实知乎 skill"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" &&
|
||||
message ==
|
||||
"loaded skills: office-export-xlsx@0.1.0, zhihu-hotlist@0.1.0, zhihu-hotlist-screen@0.1.0, zhihu-navigate@0.1.0, zhihu-write@0.1.0"
|
||||
)
|
||||
}));
|
||||
assert!(loaded_skills_message.contains("office-export-xlsx@0.1.0"));
|
||||
assert!(loaded_skills_message.contains("zhihu-hotlist@0.1.0"));
|
||||
assert!(loaded_skills_message.contains("zhihu-hotlist-screen@0.1.0"));
|
||||
assert!(loaded_skills_message.contains("zhihu-navigate@0.1.0"));
|
||||
assert!(loaded_skills_message.contains("zhihu-write@0.1.0"));
|
||||
assert_eq!(request_bodies.len(), 1);
|
||||
assert!(first_request.contains("office-export-xlsx"));
|
||||
assert!(first_request.contains("zhihu-hotlist"));
|
||||
@@ -2107,145 +2114,9 @@ fn handle_browser_message_executes_real_zhihu_hotlist_skill_flow() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_chains_hotlist_skill_into_office_export_tool() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let output_path = workspace_root.join("out/zhihu-hotlist.xlsx");
|
||||
let output_path_str = output_path.to_string_lossy().to_string();
|
||||
let first_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "zhihu-hotlist_extract_hotlist",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"top_n": "10"
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let third_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_3",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "openxml_office",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [
|
||||
[1, "问题一", "344万"],
|
||||
[2, "问题二", "266万"]
|
||||
],
|
||||
"output_path": output_path_str
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let fourth_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": format!("已导出知乎热榜 Excel {output_path_str}")
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, _requests, server_handle) =
|
||||
start_fake_deepseek_server(vec![first_response, third_response, fourth_response]);
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
&base_url,
|
||||
"deepseek-chat",
|
||||
Some(real_skill_lib_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]]
|
||||
}
|
||||
}),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "读取知乎热榜数据,并导出 excel 文件".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://www.zhihu.com/".to_string(),
|
||||
page_title: "知乎".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary.contains("已导出知乎热榜 Excel") && summary.contains(".xlsx")
|
||||
)
|
||||
}));
|
||||
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::LogEntry { level, message }
|
||||
if level == "info" && message == "call zhihu-hotlist.extract_hotlist"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::Eval
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() {
|
||||
fn handle_browser_message_chains_hotlist_skill_into_xlsx_export_and_auto_open() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
std::env::set_var("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1");
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
@@ -2282,6 +2153,118 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() {
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "读取知乎热榜数据,并导出 excel 文件".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://www.zhihu.com/".to_string(),
|
||||
page_title: "知乎".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let summary = task_complete_summary(&sent);
|
||||
let generated = extract_generated_artifact_path(&summary, ".xlsx");
|
||||
|
||||
assert!(summary.contains("已导出并打开知乎热榜 Excel"));
|
||||
assert!(summary.contains(".xlsx"));
|
||||
assert!(generated.exists());
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary.contains("已导出并打开知乎热榜 Excel") && summary.contains(".xlsx")
|
||||
)
|
||||
}));
|
||||
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::LogEntry { level, message }
|
||||
if level == "info" && message == "call zhihu-hotlist.extract_hotlist"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "call openxml_office"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::Eval
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, params, .. }
|
||||
if action == &Action::Navigate && params.get("sgclaw_local_dashboard_open").is_some()
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
||||
)
|
||||
}));
|
||||
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(real_skill_lib_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
success_browser_response(1, json!({ "navigated": true })),
|
||||
success_browser_response(
|
||||
2,
|
||||
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }),
|
||||
),
|
||||
success_browser_response(
|
||||
3,
|
||||
json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]]
|
||||
}
|
||||
}),
|
||||
),
|
||||
success_browser_response(4, json!({ "navigated": true })),
|
||||
]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
@@ -2299,10 +2282,43 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() {
|
||||
let sent = transport.sent_messages();
|
||||
let summary = task_complete_summary(&sent);
|
||||
let generated = extract_generated_artifact_path(&summary, ".html");
|
||||
let navigate = sent
|
||||
.iter()
|
||||
.find_map(|message| match message {
|
||||
AgentMessage::Command {
|
||||
action,
|
||||
params,
|
||||
security,
|
||||
..
|
||||
} if action == &Action::Navigate
|
||||
&& security.expected_domain == "__sgclaw_local_dashboard__" => Some((params, security)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("dashboard route should emit local-dashboard navigate request");
|
||||
|
||||
assert!(summary.contains("已生成知乎热榜大屏"));
|
||||
assert!(summary.contains("已在浏览器中打开知乎热榜大屏"));
|
||||
assert!(summary.contains(".html"));
|
||||
assert!(generated.exists());
|
||||
assert_eq!(
|
||||
navigate.0["sgclaw_local_dashboard_open"]["output_path"].as_str(),
|
||||
generated.to_str()
|
||||
);
|
||||
assert!(navigate.0["url"]
|
||||
.as_str()
|
||||
.expect("dashboard open url should be present")
|
||||
.starts_with("file://"));
|
||||
assert_eq!(
|
||||
navigate.0["sgclaw_local_dashboard_open"]["source"],
|
||||
json!("compat.workflow_executor")
|
||||
);
|
||||
assert_eq!(
|
||||
navigate.0["sgclaw_local_dashboard_open"]["kind"],
|
||||
json!("zhihu_hotlist_screen")
|
||||
);
|
||||
assert_eq!(
|
||||
navigate.0["sgclaw_local_dashboard_open"]["presentation_url"],
|
||||
navigate.0["url"]
|
||||
);
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
@@ -2330,6 +2346,13 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() {
|
||||
AgentMessage::Command { action, .. } if action == &Action::Eval
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "call openxml_office"
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
@@ -2339,9 +2362,55 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() {
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_reports_dashboard_auto_open_protocol_error_when_presentation_url_is_missing() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let browser_backend = sgclaw::browser::PipeBrowserBackend::from_inner(browser_tool);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let output_path = workspace_root.join("zhihu-hotlist-screen.html");
|
||||
fs::write(&output_path, "<html><body>fixture</body></html>").unwrap();
|
||||
let payload = json!({
|
||||
"title": "知乎热榜大屏",
|
||||
"output_path": output_path,
|
||||
"renderer": "screen_html_export",
|
||||
"row_count": 2,
|
||||
"snapshot_id": "snapshot-test",
|
||||
"presentation": {
|
||||
"mode": "new_tab",
|
||||
"title": "知乎热榜大屏",
|
||||
"open_in_new_tab": true
|
||||
}
|
||||
});
|
||||
|
||||
let summary = finalize_screen_export(&browser_backend, &payload.to_string()).unwrap();
|
||||
|
||||
assert!(summary.contains("已生成知乎热榜大屏"));
|
||||
assert!(summary.contains(output_path.to_string_lossy().as_ref()));
|
||||
assert!(summary.contains("但浏览器自动打开失败:screen_html_export did not return presentation.url"));
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, params, .. }
|
||||
if action == &Action::Navigate && params.get("sgclaw_local_dashboard_open").is_some()
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_runs_zhihu_hotlist_export_via_zeroclaw_primary_orchestration() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
std::env::set_var("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1");
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
@@ -2416,6 +2485,7 @@ fn handle_browser_message_runs_zhihu_hotlist_export_via_zeroclaw_primary_orchest
|
||||
if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
||||
)
|
||||
}));
|
||||
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2527,6 +2597,143 @@ fn browser_submit_path_prefers_zeroclaw_process_message_orchestrator_for_zhihu_p
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_generated_auto_publish_matches_primary_orchestration_gate() {
|
||||
assert!(
|
||||
sgclaw::compat::orchestration::should_use_primary_orchestration(
|
||||
"在知乎自动发表一篇名称为人工智能技能大全",
|
||||
Some("https://www.zhihu.com/"),
|
||||
Some("知乎"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_hotlist_export_route_stays_ahead_of_generated_article_publish() {
|
||||
use sgclaw::compat::workflow_executor::{detect_route, WorkflowRoute};
|
||||
|
||||
assert_eq!(
|
||||
detect_route(
|
||||
"打开知乎热榜,获取前10条数据,并导出 Excel",
|
||||
Some("https://www.zhihu.com/"),
|
||||
Some("知乎")
|
||||
),
|
||||
Some(WorkflowRoute::ZhihuHotlistExportXlsx)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_generated_auto_publish_uses_provider_and_submits_publish_without_confirmation() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "标题:人工智能技能大全\n正文:第一段内容。\n\n第二段内容。"
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
&base_url,
|
||||
"deepseek-chat",
|
||||
Some(real_skill_lib_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
success_browser_response(1, json!({ "navigated": true })),
|
||||
success_browser_response(
|
||||
2,
|
||||
json!({
|
||||
"text": {
|
||||
"status": "creator_entry_clicked",
|
||||
"current_url": "https://www.zhihu.com/creator",
|
||||
"next_url": "https://zhuanlan.zhihu.com/write"
|
||||
}
|
||||
}),
|
||||
),
|
||||
success_browser_response(3, json!({ "navigated": true })),
|
||||
success_browser_response(
|
||||
4,
|
||||
json!({
|
||||
"text": {
|
||||
"status": "editor_ready",
|
||||
"current_url": "https://zhuanlan.zhihu.com/write"
|
||||
}
|
||||
}),
|
||||
),
|
||||
success_browser_response(
|
||||
5,
|
||||
json!({
|
||||
"text": {
|
||||
"status": "publish_submitted",
|
||||
"current_url": "https://zhuanlan.zhihu.com/write",
|
||||
"title": "人工智能技能大全"
|
||||
}
|
||||
}),
|
||||
),
|
||||
]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "在知乎自动发表一篇名称为人工智能技能大全".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://www.zhihu.com/".to_string(),
|
||||
page_title: "知乎".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
|
||||
assert_eq!(request_bodies.len(), 1);
|
||||
assert!(request_bodies[0].to_string().contains("人工智能技能大全"));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary == "已提交知乎文章发布流程《人工智能技能大全》"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "call zhihu-write.fill_article_draft"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::Navigate
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary.contains("确认发布")
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_publish_task_matches_primary_orchestration_gate() {
|
||||
assert!(
|
||||
@@ -3078,71 +3285,37 @@ fn zhihu_publish_after_confirmation_reports_login_block_without_selector_probing
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_orchestration_registers_superrpa_tools_natively() {
|
||||
fn browser_orchestration_executes_hotlist_export_natively_from_hotlist_page() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let first_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "superrpa_browser",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "getText",
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"selector": "main"
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let second_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "openxml_office",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "问题一", "344万"]]
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let third_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "已导出知乎热榜 Excel"
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, requests, server_handle) =
|
||||
start_fake_deepseek_server(vec![first_response, second_response, third_response]);
|
||||
std::env::set_var("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1");
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
&base_url,
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(real_skill_lib_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
json!({ "text": "知乎热榜\n1\n问题一\n344万热度" }),
|
||||
)]));
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
success_browser_response(
|
||||
1,
|
||||
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }),
|
||||
),
|
||||
success_browser_response(
|
||||
2,
|
||||
json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]]
|
||||
}
|
||||
}),
|
||||
),
|
||||
]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
@@ -3164,22 +3337,60 @@ fn browser_orchestration_registers_superrpa_tools_natively() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
let sent = transport.sent_messages();
|
||||
assert!(
|
||||
!request_bodies.is_empty(),
|
||||
"expected provider request, sent messages were: {sent:?}"
|
||||
);
|
||||
server_handle.join().unwrap();
|
||||
let first_request = request_bodies
|
||||
.first()
|
||||
.expect("expected first provider request")
|
||||
.to_string();
|
||||
let tool_names = request_tool_names(&request_bodies[0]);
|
||||
let summary = task_complete_summary(&sent);
|
||||
let generated = extract_generated_artifact_path(&summary, ".xlsx");
|
||||
|
||||
assert!(first_request.contains("superrpa_browser"));
|
||||
assert!(tool_names.contains(&"superrpa_browser".to_string()));
|
||||
assert!(tool_names.contains(&"openxml_office".to_string()));
|
||||
assert!(summary.contains(".xlsx"));
|
||||
assert!(generated.exists());
|
||||
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::LogEntry { level, message }
|
||||
if level == "info" && message == "call zhihu-hotlist.extract_hotlist"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "call openxml_office"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::GetText
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::Eval
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::Navigate
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "mode" &&
|
||||
(message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
||||
)
|
||||
}));
|
||||
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3240,88 +3451,13 @@ fn zhihu_export_does_not_use_frontend_owned_mainline() {
|
||||
#[test]
|
||||
fn browser_skill_usage_is_execution_not_prompt_only() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
std::env::set_var("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1");
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let output_path = workspace_root.join("out/zhihu-hotlist-execution.xlsx");
|
||||
let output_path_str = output_path.to_string_lossy().to_string();
|
||||
let first_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "superrpa_browser",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "navigate",
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"url": "https://www.zhihu.com/hot"
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let second_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "superrpa_browser",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "getText",
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"selector": "main"
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let third_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_3",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "openxml_office",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [
|
||||
[1, "问题一", "344万"],
|
||||
[2, "问题二", "266万"]
|
||||
],
|
||||
"output_path": output_path_str
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let fourth_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": format!("已导出知乎热榜 Excel {output_path_str}")
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![
|
||||
first_response,
|
||||
second_response,
|
||||
third_response,
|
||||
fourth_response,
|
||||
]);
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
&base_url,
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(real_skill_lib_root().to_str().unwrap()),
|
||||
);
|
||||
@@ -3331,7 +3467,18 @@ fn browser_skill_usage_is_execution_not_prompt_only() {
|
||||
success_browser_response(1, json!({ "navigated": true })),
|
||||
success_browser_response(
|
||||
2,
|
||||
json!({ "text": "知乎热榜\n1\n问题一\n344万热度\n2\n问题二\n266万热度" }),
|
||||
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }),
|
||||
),
|
||||
success_browser_response(
|
||||
3,
|
||||
json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]]
|
||||
}
|
||||
}),
|
||||
),
|
||||
]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
@@ -3354,15 +3501,13 @@ fn browser_skill_usage_is_execution_not_prompt_only() {
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
let sent = transport.sent_messages();
|
||||
let first_request = request_bodies
|
||||
.first()
|
||||
.expect("expected first provider request")
|
||||
.to_string();
|
||||
let summary = task_complete_summary(&sent);
|
||||
let generated = extract_generated_artifact_path(&summary, ".xlsx");
|
||||
|
||||
assert!(summary.contains(".xlsx"));
|
||||
assert!(generated.exists());
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
@@ -3370,6 +3515,29 @@ fn browser_skill_usage_is_execution_not_prompt_only() {
|
||||
if *success && summary.contains(".xlsx")
|
||||
)
|
||||
}));
|
||||
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::LogEntry { level, message }
|
||||
if level == "info" && message == "call openxml_office"
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "mode" &&
|
||||
(message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
||||
)
|
||||
}));
|
||||
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
@@ -3387,7 +3555,6 @@ fn browser_skill_usage_is_execution_not_prompt_only() {
|
||||
message == "getText ol li")
|
||||
)
|
||||
}));
|
||||
assert!(!first_request.contains("Preloaded skill context:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -47,4 +47,13 @@ async fn screen_html_export_tool_renders_dashboard_html_with_presentation_contra
|
||||
assert!(html.contains("问题一"));
|
||||
assert!(html.contains("344万"));
|
||||
assert!(html.contains("const defaultPayload ="));
|
||||
assert!(html.contains("汇报摘要"));
|
||||
assert!(html.contains("fitScreenToViewport"));
|
||||
assert!(html.contains("dashboard-canvas"));
|
||||
assert!(html.contains("themeSwitcher"));
|
||||
assert!(html.contains("gov_blue_gold"));
|
||||
assert!(html.contains("tech_cyan_blue"));
|
||||
assert!(html.contains("industry_ink_green"));
|
||||
assert!(html.contains("meeting_red_gold"));
|
||||
assert!(html.contains("localStorage.setItem(\"zhihu-hotlist-theme\""));
|
||||
}
|
||||
|
||||
@@ -21,6 +21,24 @@ fn browser_init_round_trip_uses_frozen_wire_format() {
|
||||
assert_eq!(serde_json::to_string(&message).unwrap(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_lifecycle_messages_use_frozen_wire_tags() {
|
||||
let connect_raw = r#"{"type":"connect"}"#;
|
||||
let start_raw = r#"{"type":"start"}"#;
|
||||
let stop_raw = r#"{"type":"stop"}"#;
|
||||
|
||||
let connect: BrowserMessage = serde_json::from_str(connect_raw).unwrap();
|
||||
let start: BrowserMessage = serde_json::from_str(start_raw).unwrap();
|
||||
let stop: BrowserMessage = serde_json::from_str(stop_raw).unwrap();
|
||||
|
||||
assert_eq!(connect, BrowserMessage::Connect);
|
||||
assert_eq!(start, BrowserMessage::Start);
|
||||
assert_eq!(stop, BrowserMessage::Stop);
|
||||
assert_eq!(serde_json::to_string(&connect).unwrap(), connect_raw);
|
||||
assert_eq!(serde_json::to_string(&start).unwrap(), start_raw);
|
||||
assert_eq!(serde_json::to_string(&stop).unwrap(), stop_raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_serializes_action_and_security_fields() {
|
||||
let message = AgentMessage::Command {
|
||||
@@ -40,6 +58,16 @@ fn command_serializes_action_and_security_fields() {
|
||||
assert!(raw.contains(r#""expected_domain":"oa.example.com""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_status_changed_serializes_with_expected_tag() {
|
||||
let raw = serde_json::to_string(&AgentMessage::StatusChanged {
|
||||
state: "started".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(raw, r#"{"type":"status_changed","state":"started"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_deserializes_timing_and_payload() {
|
||||
let raw = r#"{"type":"response","seq":7,"success":true,"data":{"text":"提交成功"},"aom_snapshot":[],"timing":{"queue_ms":2,"exec_ms":38}}"#;
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
use serde_json::json;
|
||||
use sgclaw::agent::planner::{build_execution_preview, plan_instruction, PlannerError};
|
||||
use sgclaw::config::PlannerMode;
|
||||
use sgclaw::pipe::Action;
|
||||
|
||||
#[test]
|
||||
fn planner_module_is_explicitly_legacy_dev_only() {
|
||||
assert!(sgclaw::agent::planner::LEGACY_DEV_ONLY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_converts_baidu_search_instruction_into_three_steps() {
|
||||
let plan = plan_instruction("打开百度搜索天气").unwrap();
|
||||
|
||||
assert_eq!(plan.summary, "已在百度搜索天气");
|
||||
assert_eq!(plan.steps.len(), 3);
|
||||
assert_eq!(plan.steps[0].action, Action::Navigate);
|
||||
assert_eq!(
|
||||
plan.steps[0].params,
|
||||
json!({ "url": "https://www.baidu.com" })
|
||||
);
|
||||
assert_eq!(plan.steps[1].action, Action::Type);
|
||||
assert_eq!(
|
||||
plan.steps[1].params,
|
||||
json!({ "selector": "#kw", "text": "天气", "clear_first": true })
|
||||
);
|
||||
assert_eq!(plan.steps[2].action, Action::Click);
|
||||
assert_eq!(plan.steps[2].params, json!({ "selector": "#su" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_supports_baidu_search_variant_with_conjunction() {
|
||||
let plan = plan_instruction("打开百度并搜索电网调度").unwrap();
|
||||
|
||||
assert_eq!(plan.summary, "已在百度搜索电网调度");
|
||||
assert_eq!(plan.steps[1].params["text"], "电网调度");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_supports_zhihu_search_instruction_with_direct_search_url() {
|
||||
let plan = plan_instruction("打开知乎搜索天气").unwrap();
|
||||
|
||||
assert_eq!(plan.summary, "已在知乎搜索天气");
|
||||
assert_eq!(plan.steps.len(), 1);
|
||||
assert_eq!(plan.steps[0].action, Action::Navigate);
|
||||
assert_eq!(
|
||||
plan.steps[0].params,
|
||||
json!({ "url": "https://www.zhihu.com/search?type=content&q=%E5%A4%A9%E6%B0%94" })
|
||||
);
|
||||
assert_eq!(plan.steps[0].expected_domain, "www.zhihu.com");
|
||||
assert_eq!(
|
||||
plan.steps[0].log_message,
|
||||
"navigate https://www.zhihu.com/search?type=content&q=%E5%A4%A9%E6%B0%94"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_supports_open_zhihu_homepage_instruction() {
|
||||
let plan = plan_instruction("打开知乎").unwrap();
|
||||
|
||||
assert_eq!(plan.summary, "已打开知乎首页");
|
||||
assert_eq!(plan.steps.len(), 1);
|
||||
assert_eq!(plan.steps[0].action, Action::Navigate);
|
||||
assert_eq!(
|
||||
plan.steps[0].params,
|
||||
json!({ "url": "https://www.zhihu.com" })
|
||||
);
|
||||
assert_eq!(plan.steps[0].expected_domain, "www.zhihu.com");
|
||||
assert_eq!(plan.steps[0].log_message, "navigate https://www.zhihu.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_supports_open_baidu_homepage_instruction() {
|
||||
let plan = plan_instruction("打开百度").unwrap();
|
||||
|
||||
assert_eq!(plan.summary, "已打开百度首页");
|
||||
assert_eq!(plan.steps.len(), 1);
|
||||
assert_eq!(plan.steps[0].action, Action::Navigate);
|
||||
assert_eq!(
|
||||
plan.steps[0].params,
|
||||
json!({ "url": "https://www.baidu.com" })
|
||||
);
|
||||
assert_eq!(plan.steps[0].expected_domain, "www.baidu.com");
|
||||
assert_eq!(plan.steps[0].log_message, "navigate https://www.baidu.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_rejects_unrelated_instruction() {
|
||||
let err = plan_instruction("打开谷歌搜索天气").unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
PlannerError::UnsupportedInstruction("打开谷歌搜索天气".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_first_mode_builds_visible_preview_for_zhihu_excel_flow() {
|
||||
let preview = build_execution_preview(
|
||||
PlannerMode::ZeroclawPlanFirst,
|
||||
"读取知乎热榜数据,并导出 excel 文件",
|
||||
Some("https://www.zhihu.com/hot"),
|
||||
Some("知乎热榜"),
|
||||
)
|
||||
.expect("expected plan preview");
|
||||
|
||||
assert_eq!(preview.summary, "先规划再执行知乎热榜 Excel 导出");
|
||||
assert!(preview
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| step.contains("navigate https://www.zhihu.com/hot")));
|
||||
assert!(preview
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| step.contains("getText main")));
|
||||
assert!(preview
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| step.contains("call openxml_office")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_planner_mode_skips_runtime_preview() {
|
||||
let preview = build_execution_preview(
|
||||
PlannerMode::LegacyDeterministic,
|
||||
"打开百度搜索天气",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(preview.is_none());
|
||||
}
|
||||
922
tests/service_task_flow_test.rs
Normal file
922
tests/service_task_flow_test.rs
Normal file
@@ -0,0 +1,922 @@
|
||||
use std::io::{BufRead, BufReader, Read as _, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use reqwest::blocking::Client;
|
||||
use serde_json::{json, Value};
|
||||
use sgclaw::service::{ClientMessage, ServiceMessage};
|
||||
use tungstenite::{accept, Message};
|
||||
|
||||
const RUNTIME_DROP_PANIC_TEXT: &str =
|
||||
"Cannot drop a runtime in a context where blocking is not allowed";
|
||||
|
||||
fn read_ws_text(stream: &mut tungstenite::WebSocket<std::net::TcpStream>) -> String {
|
||||
match stream.read().unwrap() {
|
||||
Message::Text(text) => text.to_string(),
|
||||
other => panic!("expected text frame, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_fake_deepseek_server(
|
||||
responses: Vec<Value>,
|
||||
) -> (String, Arc<Mutex<Vec<Value>>>, thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let address = format!("http://{}", listener.local_addr().unwrap());
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let request_log = requests.clone();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
for response in responses {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
let (mut stream, _) = loop {
|
||||
match listener.accept() {
|
||||
Ok(pair) => break pair,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out waiting for provider request"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(err) => panic!("failed to accept provider request: {err}"),
|
||||
}
|
||||
};
|
||||
stream.set_nonblocking(false).unwrap();
|
||||
let body = match read_http_json_body(&mut stream) {
|
||||
Ok(body) => body,
|
||||
Err(_) => continue,
|
||||
};
|
||||
request_log.lock().unwrap().push(body);
|
||||
|
||||
let payload = response.to_string();
|
||||
let reply = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
payload.as_bytes().len(),
|
||||
payload
|
||||
);
|
||||
stream.write_all(reply.as_bytes()).unwrap();
|
||||
stream.flush().unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
(address, requests, handle)
|
||||
}
|
||||
|
||||
fn read_http_json_body(stream: &mut impl std::io::Read) -> Result<Value, &'static str> {
|
||||
let mut buffer = Vec::new();
|
||||
let mut headers_end = None;
|
||||
|
||||
while headers_end.is_none() {
|
||||
let mut chunk = [0_u8; 1024];
|
||||
let bytes = stream.read(&mut chunk).unwrap();
|
||||
if bytes == 0 {
|
||||
return Err("unexpected EOF while reading headers");
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..bytes]);
|
||||
headers_end = buffer.windows(4).position(|window| window == b"\r\n\r\n");
|
||||
}
|
||||
|
||||
let headers_end = headers_end.unwrap() + 4;
|
||||
let headers = String::from_utf8(buffer[..headers_end].to_vec()).unwrap();
|
||||
let Some(content_length) = headers.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().unwrap())
|
||||
}) else {
|
||||
return Err("missing content-length header");
|
||||
};
|
||||
|
||||
while buffer.len() < headers_end + content_length {
|
||||
let mut chunk = vec![0_u8; content_length];
|
||||
let bytes = stream.read(&mut chunk).unwrap();
|
||||
if bytes == 0 {
|
||||
return Err("unexpected EOF while reading body");
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..bytes]);
|
||||
}
|
||||
|
||||
Ok(serde_json::from_slice(&buffer[headers_end..headers_end + content_length]).unwrap())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum CallbackHostBrowserEvent {
|
||||
BrowserFrame(Value),
|
||||
CommandEnvelope(Value),
|
||||
}
|
||||
|
||||
fn start_callback_host_hotlist_browser_server(
|
||||
event_tx: mpsc::Sender<CallbackHostBrowserEvent>,
|
||||
) -> (String, thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
|
||||
stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
|
||||
let mut websocket = accept(stream).unwrap();
|
||||
|
||||
let register = match websocket.read().unwrap() {
|
||||
Message::Text(text) => serde_json::from_str::<Value>(&text).unwrap(),
|
||||
other => panic!("expected register frame, got {other:?}"),
|
||||
};
|
||||
event_tx
|
||||
.send(CallbackHostBrowserEvent::BrowserFrame(register))
|
||||
.unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
r#"{"type":"welcome","client_id":1,"server_time":"2026-04-04T00:00:00"}"#
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let first_action = match websocket.read().unwrap() {
|
||||
Message::Text(text) => serde_json::from_str::<Value>(&text).unwrap(),
|
||||
other => panic!("expected browser action frame, got {other:?}"),
|
||||
};
|
||||
event_tx
|
||||
.send(CallbackHostBrowserEvent::BrowserFrame(first_action.clone()))
|
||||
.unwrap();
|
||||
|
||||
let Some(values) = first_action.as_array() else {
|
||||
websocket.close(None).ok();
|
||||
return;
|
||||
};
|
||||
let is_helper_open = values.len() >= 3
|
||||
&& values[1] == json!("sgBrowerserOpenPage")
|
||||
&& values[2]
|
||||
.as_str()
|
||||
.is_some_and(|url| url.ends_with("/sgclaw/browser-helper.html"));
|
||||
if !is_helper_open {
|
||||
websocket.close(None).ok();
|
||||
return;
|
||||
}
|
||||
|
||||
let helper_url = values[2].as_str().unwrap().to_string();
|
||||
let helper_origin = helper_url
|
||||
.trim_end_matches("/sgclaw/browser-helper.html")
|
||||
.to_string();
|
||||
let helper_client = Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap();
|
||||
let helper_html = helper_client
|
||||
.get(&helper_url)
|
||||
.send()
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.text()
|
||||
.unwrap();
|
||||
assert!(helper_html.contains("sgclawReady"));
|
||||
assert!(helper_html.contains("sgclawOnLoaded"));
|
||||
assert!(helper_html.contains("sgclawOnGetText"));
|
||||
assert!(helper_html.contains("sgclawOnEval"));
|
||||
|
||||
let pre_ready_command: Value = helper_client
|
||||
.get(format!("{helper_origin}/sgclaw/callback/commands/next"))
|
||||
.send()
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(CallbackHostBrowserEvent::CommandEnvelope(pre_ready_command))
|
||||
.unwrap();
|
||||
|
||||
helper_client
|
||||
.post(format!("{helper_origin}/sgclaw/callback/ready"))
|
||||
.json(&json!({
|
||||
"type": "ready",
|
||||
"helper_url": helper_url,
|
||||
}))
|
||||
.send()
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
|
||||
let hotlist_text = "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度";
|
||||
let hotlist_payload = json!({
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]]
|
||||
})
|
||||
.to_string();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let mut saw_get_text = false;
|
||||
let mut saw_eval = false;
|
||||
|
||||
while Instant::now() < deadline {
|
||||
let envelope: Value = helper_client
|
||||
.get(format!("{helper_origin}/sgclaw/callback/commands/next"))
|
||||
.send()
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.unwrap();
|
||||
let Some(command) = envelope.get("command").and_then(Value::as_object) else {
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
continue;
|
||||
};
|
||||
event_tx
|
||||
.send(CallbackHostBrowserEvent::CommandEnvelope(envelope.clone()))
|
||||
.unwrap();
|
||||
|
||||
let action_name = command
|
||||
.get("action")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
helper_client
|
||||
.post(format!("{helper_origin}/sgclaw/callback/commands/ack"))
|
||||
.json(&json!({ "type": "command_ack" }))
|
||||
.send()
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
|
||||
let args = command
|
||||
.get("args")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
match action_name.as_str() {
|
||||
"sgBrowerserOpenPage" => {}
|
||||
"sgBrowserExcuteJsCodeByDomain" => {
|
||||
let script = args.get(1).and_then(Value::as_str).unwrap_or_default();
|
||||
if script.contains("sgclawOnGetText") {
|
||||
saw_get_text = true;
|
||||
helper_client
|
||||
.post(format!("{helper_origin}/sgclaw/callback/events"))
|
||||
.json(&json!({
|
||||
"callback": "sgclawOnGetText",
|
||||
"request_url": helper_url,
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"action": action_name,
|
||||
"payload": { "text": hotlist_text }
|
||||
}))
|
||||
.send()
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
} else if script.contains("sgclawOnEval") {
|
||||
saw_eval = true;
|
||||
helper_client
|
||||
.post(format!("{helper_origin}/sgclaw/callback/events"))
|
||||
.json(&json!({
|
||||
"callback": "sgclawOnEval",
|
||||
"request_url": helper_url,
|
||||
"target_url": "https://www.zhihu.com/hot",
|
||||
"action": action_name,
|
||||
"payload": { "value": hotlist_payload }
|
||||
}))
|
||||
.send()
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
break;
|
||||
} else {
|
||||
panic!("unexpected callback-host domain command: {script}");
|
||||
}
|
||||
}
|
||||
other => panic!("unexpected callback-host command action {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw_get_text, "expected callback-host getText command");
|
||||
assert!(saw_eval, "expected callback-host eval command");
|
||||
websocket.close(None).ok();
|
||||
});
|
||||
|
||||
(format!("ws://{address}"), handle)
|
||||
}
|
||||
|
||||
fn start_direct_zhihu_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(5))).unwrap();
|
||||
stream.set_write_timeout(Some(Duration::from_secs(5))).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;
|
||||
}
|
||||
}
|
||||
|
||||
socket.close(None).ok();
|
||||
});
|
||||
|
||||
(format!("ws://{address}"), frames, handle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_submits_first_user_line_to_service() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let ws_url = format!("ws://{address}");
|
||||
|
||||
let server = thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut websocket = accept(stream).unwrap();
|
||||
let payload = read_ws_text(&mut websocket);
|
||||
let request: ClientMessage = serde_json::from_str(&payload).unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ServiceMessage::TaskComplete {
|
||||
success: true,
|
||||
summary: "done".to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.unwrap();
|
||||
websocket.close(None).unwrap();
|
||||
request
|
||||
});
|
||||
|
||||
let mut child = std::process::Command::new(
|
||||
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
|
||||
)
|
||||
.env("SG_CLAW_SERVICE_WS_URL", &ws_url)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_all("打开百度搜索天气\n".as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let status = child.wait().unwrap();
|
||||
assert!(status.success());
|
||||
|
||||
let request = server.join().unwrap();
|
||||
assert_eq!(
|
||||
request,
|
||||
ClientMessage::SubmitTask {
|
||||
instruction: "打开百度搜索天气".to_string(),
|
||||
conversation_id: "".to_string(),
|
||||
messages: vec![],
|
||||
page_url: "".to_string(),
|
||||
page_title: "".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_sends_connect_request_and_exits_after_status() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let ws_url = format!("ws://{address}");
|
||||
|
||||
let server = thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut websocket = accept(stream).unwrap();
|
||||
let payload = read_ws_text(&mut websocket);
|
||||
let request: ClientMessage = serde_json::from_str(&payload).unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ServiceMessage::StatusChanged {
|
||||
state: "connected".to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ServiceMessage::StatusChanged {
|
||||
state: "connected again".to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.unwrap();
|
||||
websocket.close(None).unwrap();
|
||||
request
|
||||
});
|
||||
|
||||
let mut child = std::process::Command::new(
|
||||
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
|
||||
)
|
||||
.env("SG_CLAW_SERVICE_WS_URL", &ws_url)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_all("/connect\n".as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let output = child.wait_with_output().unwrap();
|
||||
let request = server.join().unwrap();
|
||||
|
||||
assert!(output.status.success());
|
||||
assert_eq!(request, ClientMessage::Connect);
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(stdout.lines().collect::<Vec<_>>(), vec!["status: connected"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_sends_start_and_stop_requests_with_explicit_commands() {
|
||||
for (input, expected_request, expected_status) in [
|
||||
("/start\n", ClientMessage::Start, "status: started"),
|
||||
("/stop\n", ClientMessage::Stop, "status: stopped"),
|
||||
] {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let ws_url = format!("ws://{address}");
|
||||
let expected_state = expected_status.trim_start_matches("status: ").to_string();
|
||||
|
||||
let server = thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut websocket = accept(stream).unwrap();
|
||||
let payload = read_ws_text(&mut websocket);
|
||||
let request: ClientMessage = serde_json::from_str(&payload).unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ServiceMessage::StatusChanged {
|
||||
state: expected_state,
|
||||
})
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.unwrap();
|
||||
websocket.close(None).unwrap();
|
||||
request
|
||||
});
|
||||
|
||||
let mut child = std::process::Command::new(
|
||||
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
|
||||
)
|
||||
.env("SG_CLAW_SERVICE_WS_URL", &ws_url)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_all(input.as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let output = child.wait_with_output().unwrap();
|
||||
let request = server.join().unwrap();
|
||||
|
||||
assert!(output.status.success());
|
||||
assert_eq!(request, expected_request);
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(stdout.lines().collect::<Vec<_>>(), vec![expected_status]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_prints_completion_only_once() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let ws_url = format!("ws://{address}");
|
||||
|
||||
let server = thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut websocket = accept(stream).unwrap();
|
||||
let payload = read_ws_text(&mut websocket);
|
||||
let request: ClientMessage = serde_json::from_str(&payload).unwrap();
|
||||
assert_eq!(request.into_submit_task_request().unwrap().instruction, "打开百度搜索天气");
|
||||
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ServiceMessage::TaskComplete {
|
||||
success: true,
|
||||
summary: "done".to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ServiceMessage::TaskComplete {
|
||||
success: true,
|
||||
summary: "done again".to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.unwrap();
|
||||
websocket.close(None).unwrap();
|
||||
});
|
||||
|
||||
let mut child = std::process::Command::new(
|
||||
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
|
||||
)
|
||||
.env("SG_CLAW_SERVICE_WS_URL", &ws_url)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_all("打开百度搜索天气\n".as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let output = child.wait_with_output().unwrap();
|
||||
server.join().unwrap();
|
||||
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(stdout.lines().collect::<Vec<_>>(), vec!["done"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_prints_log_entries_in_order_before_completion() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let ws_url = format!("ws://{address}");
|
||||
|
||||
let server = thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut websocket = accept(stream).unwrap();
|
||||
let payload = read_ws_text(&mut websocket);
|
||||
let request: ClientMessage = serde_json::from_str(&payload).unwrap();
|
||||
assert_eq!(request.into_submit_task_request().unwrap().instruction, "打开百度搜索天气");
|
||||
|
||||
for message in [
|
||||
ServiceMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: "step 1".to_string(),
|
||||
},
|
||||
ServiceMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
message: "step 2".to_string(),
|
||||
},
|
||||
ServiceMessage::TaskComplete {
|
||||
success: true,
|
||||
summary: "done".to_string(),
|
||||
},
|
||||
] {
|
||||
websocket
|
||||
.send(Message::Text(serde_json::to_string(&message).unwrap().into()))
|
||||
.unwrap();
|
||||
}
|
||||
websocket.close(None).unwrap();
|
||||
});
|
||||
|
||||
let mut child = std::process::Command::new(
|
||||
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
|
||||
)
|
||||
.env("SG_CLAW_SERVICE_WS_URL", &ws_url)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_all("打开百度搜索天气\n".as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let reader = thread::spawn(move || {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
tx.send(line.unwrap()).unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
let first = rx.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
let second = rx.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
let third = rx.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
|
||||
let status = child.wait().unwrap();
|
||||
reader.join().unwrap();
|
||||
server.join().unwrap();
|
||||
|
||||
assert!(status.success());
|
||||
assert_eq!(vec![first, second, third], vec!["step 1", "step 2", "done"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_exits_with_failure_when_service_disconnects_before_completion() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let ws_url = format!("ws://{address}");
|
||||
|
||||
let server = thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut websocket = accept(stream).unwrap();
|
||||
let payload = read_ws_text(&mut websocket);
|
||||
let request: ClientMessage = serde_json::from_str(&payload).unwrap();
|
||||
websocket.close(None).unwrap();
|
||||
request
|
||||
});
|
||||
|
||||
let mut child = std::process::Command::new(
|
||||
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
|
||||
)
|
||||
.env("SG_CLAW_SERVICE_WS_URL", &ws_url)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_all("打开百度搜索天气\n".as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let status = child.wait().unwrap();
|
||||
assert!(!status.success());
|
||||
|
||||
let request = server.join().unwrap();
|
||||
assert_eq!(request.into_submit_task_request().unwrap().instruction, "打开百度搜索天气");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_to_service_regression_routes_zhihu_through_callback_host_without_invalid_hmac_seed_output() {
|
||||
let service_listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let service_addr = service_listener.local_addr().unwrap();
|
||||
drop(service_listener);
|
||||
|
||||
let (event_tx, event_rx) = mpsc::channel();
|
||||
let (browser_ws_url, browser_server) = start_callback_host_hotlist_browser_server(event_tx);
|
||||
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-service-task-flow-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
r#"{{
|
||||
"apiKey": "sk-runtime",
|
||||
"baseUrl": "http://127.0.0.1:9",
|
||||
"model": "deepseek-chat",
|
||||
"browserWsUrl": "{browser_ws_url}",
|
||||
"serviceWsListenAddr": "{service_addr}"
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut service = std::process::Command::new(
|
||||
std::env::var("CARGO_BIN_EXE_sg_claw").expect("sg_claw test binary path"),
|
||||
)
|
||||
.env("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1")
|
||||
.arg("--config-path")
|
||||
.arg(&config_path)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let ws_url = format!("ws://{service_addr}");
|
||||
let ready_deadline = Instant::now() + Duration::from_secs(2);
|
||||
let mut service_stderr_boot = String::new();
|
||||
while Instant::now() < ready_deadline {
|
||||
if let Some(stream) = service.stderr.as_mut() {
|
||||
let mut buf = [0_u8; 1024];
|
||||
match stream.read(&mut buf) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => {
|
||||
service_stderr_boot.push_str(&String::from_utf8_lossy(&buf[..n]));
|
||||
if service_stderr_boot.contains("sg_claw ready:") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
if service.try_wait().unwrap().is_some() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert!(
|
||||
service_stderr_boot.contains("sg_claw ready:"),
|
||||
"service did not report readiness; stderr={service_stderr_boot}"
|
||||
);
|
||||
|
||||
let mut client = std::process::Command::new(
|
||||
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
|
||||
)
|
||||
.env("SG_CLAW_SERVICE_WS_URL", &ws_url)
|
||||
.env("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
client
|
||||
.stdin
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_all("打开知乎热榜,获取前10条数据,并导出 Excel\n".as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let client_output = client.wait_with_output().unwrap();
|
||||
browser_server.join().unwrap();
|
||||
|
||||
let register = event_rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let bootstrap = event_rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let pre_ready = event_rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let open_page = event_rx.recv_timeout(Duration::from_secs(4)).unwrap();
|
||||
let get_text = event_rx.recv_timeout(Duration::from_secs(4)).unwrap();
|
||||
let eval = event_rx.recv_timeout(Duration::from_secs(4)).unwrap();
|
||||
|
||||
let exit_deadline = Instant::now() + Duration::from_secs(1);
|
||||
let mut service_status = None;
|
||||
while Instant::now() < exit_deadline {
|
||||
if let Some(status) = service.try_wait().unwrap() {
|
||||
service_status = Some(status);
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
if service_status.is_none() {
|
||||
service.kill().unwrap();
|
||||
let _ = service.wait();
|
||||
}
|
||||
let service_stdout = service
|
||||
.stdout
|
||||
.take()
|
||||
.map(|mut stream| {
|
||||
let mut buf = Vec::new();
|
||||
let _ = stream.read_to_end(&mut buf);
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let service_stderr = service
|
||||
.stderr
|
||||
.take()
|
||||
.map(|mut stream| {
|
||||
let mut buf = Vec::new();
|
||||
let _ = stream.read_to_end(&mut buf);
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let client_stdout = String::from_utf8_lossy(&client_output.stdout).into_owned();
|
||||
let client_stderr = String::from_utf8_lossy(&client_output.stderr).into_owned();
|
||||
let combined_output = format!("{client_stdout}\n{client_stderr}\n{service_stdout}\n{service_stderr}");
|
||||
|
||||
let register = match register {
|
||||
CallbackHostBrowserEvent::BrowserFrame(value) => value,
|
||||
other => panic!("expected register browser frame, got {other:?}"),
|
||||
};
|
||||
assert_eq!(register, json!({ "type": "register", "role": "web" }));
|
||||
|
||||
let bootstrap = match bootstrap {
|
||||
CallbackHostBrowserEvent::BrowserFrame(value) => value,
|
||||
other => panic!("expected helper bootstrap frame, got {other:?}"),
|
||||
};
|
||||
assert_eq!(bootstrap[0], json!("https://www.zhihu.com"));
|
||||
assert_eq!(bootstrap[1], json!("sgBrowerserOpenPage"));
|
||||
assert!(bootstrap[2]
|
||||
.as_str()
|
||||
.is_some_and(|url| url.ends_with("/sgclaw/browser-helper.html")));
|
||||
|
||||
let pre_ready = match pre_ready {
|
||||
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
|
||||
other => panic!("expected pre-ready command envelope, got {other:?}"),
|
||||
};
|
||||
assert_eq!(pre_ready, json!({ "ok": false, "command": null }));
|
||||
|
||||
let open_page = match open_page {
|
||||
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
|
||||
other => panic!("expected open-page command envelope, got {other:?}"),
|
||||
};
|
||||
assert_eq!(open_page["command"]["action"], json!("sgBrowerserOpenPage"));
|
||||
assert_eq!(open_page["command"]["args"][0], json!("https://www.zhihu.com/hot"));
|
||||
|
||||
let get_text = match get_text {
|
||||
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
|
||||
other => panic!("expected getText command envelope, got {other:?}"),
|
||||
};
|
||||
assert_eq!(get_text["command"]["action"], json!("sgBrowserExcuteJsCodeByDomain"));
|
||||
assert_eq!(get_text["command"]["args"][0], json!("www.zhihu.com"));
|
||||
assert!(get_text["command"]["args"][1]
|
||||
.as_str()
|
||||
.is_some_and(|script| script.contains("sgclawOnGetText")));
|
||||
|
||||
let eval = match eval {
|
||||
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
|
||||
other => panic!("expected eval command envelope, got {other:?}"),
|
||||
};
|
||||
assert_eq!(eval["command"]["action"], json!("sgBrowserExcuteJsCodeByDomain"));
|
||||
assert_eq!(eval["command"]["args"][0], json!("www.zhihu.com"));
|
||||
assert!(eval["command"]["args"][1]
|
||||
.as_str()
|
||||
.is_some_and(|script| script.contains("sgclawOnEval")));
|
||||
|
||||
assert!(client_output.status.success());
|
||||
assert!(client_stdout.contains("已导出并打开知乎热榜 Excel"), "client stdout={client_stdout}");
|
||||
assert!(client_stdout.contains(".xlsx"), "client stdout={client_stdout}");
|
||||
assert!(
|
||||
!combined_output.contains("invalid hmac seed: session key must not be empty"),
|
||||
"target behavior must avoid the invalid hmac seed failure; combined_output={combined_output}"
|
||||
);
|
||||
assert!(
|
||||
!combined_output.contains(RUNTIME_DROP_PANIC_TEXT),
|
||||
"target behavior must avoid the runtime-drop panic; combined_output={combined_output}"
|
||||
);
|
||||
}
|
||||
1380
tests/service_ws_session_test.rs
Normal file
1380
tests/service_ws_session_test.rs
Normal file
File diff suppressed because it is too large
Load Diff
471
tests/task_runner_test.rs
Normal file
471
tests/task_runner_test.rs
Normal file
@@ -0,0 +1,471 @@
|
||||
mod common;
|
||||
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use common::MockTransport;
|
||||
use serde_json::Value;
|
||||
use sgclaw::agent::{run_submit_task, AgentEventSink, AgentRuntimeContext, SubmitTaskRequest};
|
||||
use sgclaw::agent::task_runner::run_submit_task_with_browser_backend;
|
||||
use sgclaw::browser::BrowserBackend;
|
||||
use sgclaw::pipe::{
|
||||
Action, AgentMessage, BrowserMessage, BrowserPipeTool, CommandOutput, ConversationMessage,
|
||||
ExecutionSurfaceMetadata, PipeError, Timing,
|
||||
};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn test_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["oa.example.com", "www.baidu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText"],
|
||||
"blocked": ["eval", "executeJsInPage"]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn test_browser_tool(transport: Arc<MockTransport>) -> BrowserPipeTool<MockTransport> {
|
||||
BrowserPipeTool::new(
|
||||
transport,
|
||||
test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1))
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct StubBrowserBackend;
|
||||
|
||||
impl BrowserBackend for StubBrowserBackend {
|
||||
fn invoke(
|
||||
&self,
|
||||
_action: Action,
|
||||
_params: Value,
|
||||
_expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
Err(PipeError::Protocol(
|
||||
"stub backend should not be invoked in this test".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
ExecutionSurfaceMetadata::privileged_browser_pipe("stub-backend")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingSink {
|
||||
sent: Mutex<Vec<AgentMessage>>,
|
||||
}
|
||||
|
||||
impl RecordingSink {
|
||||
fn sent_messages(&self) -> Vec<AgentMessage> {
|
||||
self.sent.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentEventSink for RecordingSink {
|
||||
fn send(&self, message: &AgentMessage) -> Result<(), PipeError> {
|
||||
self.sent.lock().unwrap().push(message.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_workspace_root() -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-task-runner-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_submit_task_with_browser_backend_accepts_ws_only_backend_and_preserves_existing_entry() {
|
||||
let _existing_entry: fn(
|
||||
&MockTransport,
|
||||
&dyn AgentEventSink,
|
||||
&BrowserPipeTool<MockTransport>,
|
||||
&AgentRuntimeContext,
|
||||
SubmitTaskRequest,
|
||||
) -> Result<(), PipeError> = run_submit_task::<MockTransport>;
|
||||
let _ws_only_entry: fn(
|
||||
&MockTransport,
|
||||
&dyn AgentEventSink,
|
||||
Arc<dyn BrowserBackend>,
|
||||
&AgentRuntimeContext,
|
||||
SubmitTaskRequest,
|
||||
) -> Result<(), PipeError> = run_submit_task_with_browser_backend::<MockTransport>;
|
||||
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
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 sink = RecordingSink::default();
|
||||
let backend: Arc<dyn BrowserBackend> = Arc::new(StubBrowserBackend);
|
||||
|
||||
run_submit_task_with_browser_backend(
|
||||
transport.as_ref(),
|
||||
&sink,
|
||||
backend,
|
||||
&AgentRuntimeContext::default(),
|
||||
SubmitTaskRequest {
|
||||
instruction: "打开百度搜索天气".to_string(),
|
||||
..SubmitTaskRequest::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = sink.sent_messages();
|
||||
assert!(transport.sent_messages().is_empty());
|
||||
assert_eq!(sent.len(), 2);
|
||||
assert!(matches!(&sent[0], AgentMessage::LogEntry { level, .. } if level == "info"));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if !success && summary.contains("未配置大语言模型")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_submit_task_rejects_blank_instruction_without_emitting_logs() {
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = test_browser_tool(transport.clone());
|
||||
|
||||
run_submit_task(
|
||||
transport.as_ref(),
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&AgentRuntimeContext::default(),
|
||||
SubmitTaskRequest {
|
||||
instruction: " ".to_string(),
|
||||
..SubmitTaskRequest::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert_eq!(
|
||||
sent[0],
|
||||
AgentMessage::TaskComplete {
|
||||
success: false,
|
||||
summary: "请输入任务内容。".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_submit_task_can_emit_to_custom_sink() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
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 = test_browser_tool(transport.clone());
|
||||
let sink = RecordingSink::default();
|
||||
|
||||
run_submit_task(
|
||||
transport.as_ref(),
|
||||
&sink,
|
||||
&browser_tool,
|
||||
&AgentRuntimeContext::default(),
|
||||
SubmitTaskRequest {
|
||||
instruction: "打开百度搜索天气".to_string(),
|
||||
messages: vec![ConversationMessage {
|
||||
role: "user".to_string(),
|
||||
content: "上一轮问题".to_string(),
|
||||
}],
|
||||
..SubmitTaskRequest::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = sink.sent_messages();
|
||||
assert!(transport.sent_messages().is_empty());
|
||||
assert_eq!(sent.len(), 3);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info"
|
||||
&& message
|
||||
== &format!(
|
||||
"sgclaw runtime version={} protocol={}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
sgclaw::pipe::protocol::PROTOCOL_VERSION
|
||||
)
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "continuing conversation with 1 prior turns"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[2],
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if !success && summary.contains("未配置大语言模型")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_submit_task_without_llm_config_emits_runtime_version_then_failure() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
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 = test_browser_tool(transport.clone());
|
||||
|
||||
run_submit_task(
|
||||
transport.as_ref(),
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&AgentRuntimeContext::default(),
|
||||
SubmitTaskRequest {
|
||||
instruction: "打开百度搜索天气".to_string(),
|
||||
..SubmitTaskRequest::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert_eq!(sent.len(), 2);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info"
|
||||
&& message
|
||||
== &format!(
|
||||
"sgclaw runtime version={} protocol={}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
sgclaw::pipe::protocol::PROTOCOL_VERSION
|
||||
)
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if !success && summary.contains("未配置大语言模型")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_submit_task_logs_prior_turn_count_before_completion() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
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 = test_browser_tool(transport.clone());
|
||||
|
||||
run_submit_task(
|
||||
transport.as_ref(),
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&AgentRuntimeContext::default(),
|
||||
SubmitTaskRequest {
|
||||
instruction: "继续处理当前页面".to_string(),
|
||||
messages: vec![
|
||||
ConversationMessage {
|
||||
role: "user".to_string(),
|
||||
content: "上一轮问题".to_string(),
|
||||
},
|
||||
ConversationMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: "上一轮回答".to_string(),
|
||||
},
|
||||
],
|
||||
..SubmitTaskRequest::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert_eq!(sent.len(), 3);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info"
|
||||
&& message
|
||||
== &format!(
|
||||
"sgclaw runtime version={} protocol={}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
sgclaw::pipe::protocol::PROTOCOL_VERSION
|
||||
)
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "continuing conversation with 2 prior turns"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[2],
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if !success && summary.contains("未配置大语言模型")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_submit_task_reports_settings_load_error_and_final_failure() {
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = workspace_root.join("sgclaw_config.json");
|
||||
fs::write(&config_path, "{").unwrap();
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = test_browser_tool(transport.clone());
|
||||
let context = AgentRuntimeContext::new(Some(config_path), workspace_root);
|
||||
|
||||
run_submit_task(
|
||||
transport.as_ref(),
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&context,
|
||||
SubmitTaskRequest {
|
||||
instruction: "打开百度".to_string(),
|
||||
..SubmitTaskRequest::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert_eq!(sent.len(), 3);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info"
|
||||
&& message
|
||||
== &format!(
|
||||
"sgclaw runtime version={} protocol={}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
sgclaw::pipe::protocol::PROTOCOL_VERSION
|
||||
)
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "error" && message.starts_with("failed to load DeepSeek config:")
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[2],
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if !success && summary.contains("invalid DeepSeek config JSON")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_normalizes_empty_optional_submit_fields() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
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 = test_browser_tool(transport.clone());
|
||||
|
||||
sgclaw::agent::handle_browser_message(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "打开百度".to_string(),
|
||||
conversation_id: " ".to_string(),
|
||||
messages: vec![],
|
||||
page_url: "".to_string(),
|
||||
page_title: "\n\t".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert_eq!(sent.len(), 2);
|
||||
assert!(matches!(&sent[0], AgentMessage::LogEntry { .. }));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if !success && summary.contains("未配置大语言模型")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_emits_status_for_lifecycle_messages() {
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = test_browser_tool(transport.clone());
|
||||
|
||||
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();
|
||||
|
||||
assert_eq!(
|
||||
transport.sent_messages(),
|
||||
vec![
|
||||
AgentMessage::StatusChanged {
|
||||
state: "connected".to_string(),
|
||||
},
|
||||
AgentMessage::StatusChanged {
|
||||
state: "started".to_string(),
|
||||
},
|
||||
AgentMessage::StatusChanged {
|
||||
state: "stopped".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_still_ignores_init_and_unsolicited_response() {
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = test_browser_tool(transport.clone());
|
||||
|
||||
sgclaw::agent::handle_browser_message(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
BrowserMessage::Init {
|
||||
version: "1.0".to_string(),
|
||||
hmac_seed: "seed".to_string(),
|
||||
capabilities: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
sgclaw::agent::handle_browser_message(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: serde_json::json!({}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(transport.sent_messages().is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user