feat: restore zhihu browser skills
Reconnect the recovered Zhihu skill flows to the live browser runtime and resolve their resources relative to the executable so they work outside the repo root. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ impl MockTransport {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn sent_messages(&self) -> Vec<AgentMessage> {
|
||||
self.sent.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ fn test_policy() -> MacPolicy {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn build_adapter(messages: Vec<BrowserMessage>) -> (Arc<MockTransport>, ZeroClawBrowserTool<MockTransport>) {
|
||||
fn build_adapter(
|
||||
messages: Vec<BrowserMessage>,
|
||||
) -> (Arc<MockTransport>, ZeroClawBrowserTool<MockTransport>) {
|
||||
let transport = Arc::new(MockTransport::new(messages));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
@@ -193,13 +195,11 @@ async fn zeroclaw_browser_tool_keeps_domain_validation_in_mac_policy() {
|
||||
assert!(!result.success);
|
||||
assert!(result.output.is_empty());
|
||||
assert_eq!(transport.sent_messages().len(), 0);
|
||||
assert!(
|
||||
result
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("domain is not allowed")
|
||||
);
|
||||
assert!(result
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("domain is not allowed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -232,25 +232,19 @@ async fn zeroclaw_browser_tool_rejects_missing_required_action_parameters() {
|
||||
assert!(!missing_text_selector.success);
|
||||
assert!(!missing_navigate_url.success);
|
||||
assert_eq!(transport.sent_messages().len(), 0);
|
||||
assert!(
|
||||
missing_click_selector
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("click requires selector")
|
||||
);
|
||||
assert!(
|
||||
missing_text_selector
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("getText requires selector")
|
||||
);
|
||||
assert!(
|
||||
missing_navigate_url
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("navigate requires url")
|
||||
);
|
||||
assert!(missing_click_selector
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("click requires selector"));
|
||||
assert!(missing_text_selector
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("getText requires selector"));
|
||||
assert!(missing_navigate_url
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("navigate requires url"));
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@ use std::path::Path;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use sgclaw::compat::config_adapter::{
|
||||
build_zeroclaw_config,
|
||||
build_zeroclaw_config_from_settings,
|
||||
zeroclaw_workspace_dir,
|
||||
build_zeroclaw_config, build_zeroclaw_config_from_settings, zeroclaw_workspace_dir,
|
||||
};
|
||||
use sgclaw::config::DeepSeekSettings;
|
||||
use uuid::Uuid;
|
||||
@@ -49,11 +47,17 @@ fn zeroclaw_config_adapter_uses_deterministic_workspace_dir() {
|
||||
let workspace_dir = zeroclaw_workspace_dir(Path::new("/var/lib/sgclaw"));
|
||||
let config = build_zeroclaw_config_from_settings(Path::new("/var/lib/sgclaw"), &settings);
|
||||
|
||||
assert_eq!(workspace_dir, Path::new("/var/lib/sgclaw/.sgclaw-zeroclaw-workspace"));
|
||||
assert_eq!(
|
||||
workspace_dir,
|
||||
Path::new("/var/lib/sgclaw/.sgclaw-zeroclaw-workspace")
|
||||
);
|
||||
assert_eq!(config.workspace_dir, workspace_dir);
|
||||
assert_eq!(config.default_provider.as_deref(), Some("deepseek"));
|
||||
assert_eq!(config.default_model.as_deref(), Some("deepseek-reasoner"));
|
||||
assert_eq!(config.api_url.as_deref(), Some("https://proxy.example.com/v1"));
|
||||
assert_eq!(
|
||||
config.api_url.as_deref(),
|
||||
Some("https://proxy.example.com/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,7 +3,7 @@ mod common;
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
@@ -11,9 +11,7 @@ use std::time::Duration;
|
||||
use common::MockTransport;
|
||||
use serde_json::{json, Value};
|
||||
use sgclaw::agent::{
|
||||
handle_browser_message,
|
||||
handle_browser_message_with_context,
|
||||
AgentRuntimeContext,
|
||||
handle_browser_message, handle_browser_message_with_context, AgentRuntimeContext,
|
||||
};
|
||||
use sgclaw::compat::runtime::{execute_task, CompatTaskContext};
|
||||
use sgclaw::config::DeepSeekSettings;
|
||||
@@ -48,7 +46,7 @@ fn temp_workspace_root() -> PathBuf {
|
||||
root
|
||||
}
|
||||
|
||||
fn write_deepseek_config(root: &PathBuf, api_key: &str, base_url: &str, model: &str) -> PathBuf {
|
||||
fn write_deepseek_config(root: &Path, api_key: &str, base_url: &str, model: &str) -> PathBuf {
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
fs::write(
|
||||
&config_path,
|
||||
@@ -94,7 +92,7 @@ fn start_fake_deepseek_server(
|
||||
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.len(),
|
||||
payload
|
||||
);
|
||||
stream.write_all(reply.as_bytes()).unwrap();
|
||||
@@ -281,7 +279,8 @@ fn compat_runtime_uses_zeroclaw_provider_path_and_executes_browser_actions() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_prefers_compat_runtime_for_supported_instruction_when_deepseek_is_configured() {
|
||||
fn handle_browser_message_prefers_compat_runtime_for_supported_instruction_when_deepseek_is_configured(
|
||||
) {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let first_response = json!({
|
||||
@@ -643,11 +642,9 @@ fn compat_runtime_includes_prior_turns_in_follow_up_provider_request() {
|
||||
|
||||
assert_eq!(summary, "已在知乎搜索天气");
|
||||
assert!(first_request_messages.iter().any(|message| {
|
||||
message["role"] == json!("user")
|
||||
&& message["content"] == json!("打开百度搜索天气")
|
||||
message["role"] == json!("user") && message["content"] == json!("打开百度搜索天气")
|
||||
}));
|
||||
assert!(first_request_messages.iter().any(|message| {
|
||||
message["role"] == json!("assistant")
|
||||
&& message["content"] == json!("已在百度搜索天气")
|
||||
message["role"] == json!("assistant") && message["content"] == json!("已在百度搜索天气")
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -60,8 +60,5 @@ fn deepseek_request_shape_matches_openai_compatible_chat_format() {
|
||||
assert_eq!(serialized["messages"][0]["role"], "system");
|
||||
assert_eq!(serialized["messages"][1]["content"], "打开百度搜索天气");
|
||||
assert_eq!(serialized["tools"][0]["type"], "function");
|
||||
assert_eq!(
|
||||
serialized["tools"][0]["function"]["name"],
|
||||
"browser_action"
|
||||
);
|
||||
assert_eq!(serialized["tools"][0]["function"]["name"], "browser_action");
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ fn test_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["oa.example.com", "www.baidu.com"] },
|
||||
"domains": { "allowed": ["oa.example.com", "www.baidu.com", "www.zhihu.com", "zhuanlan.zhihu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText"],
|
||||
"allowed": ["click", "type", "navigate", "getText", "getHtml", "waitForSelector", "scrollTo"],
|
||||
"blocked": ["eval", "executeJsInPage"]
|
||||
}
|
||||
}"#,
|
||||
@@ -120,3 +120,116 @@ fn submit_task_sends_three_commands_and_finishes_with_task_complete() {
|
||||
if *success && summary == "已在百度搜索天气"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_zhihu_skill_short_circuits_before_planner_fallback() {
|
||||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: serde_json::json!({ "url": "https://www.zhihu.com/creator/analytics/work/all" }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 20,
|
||||
},
|
||||
}]));
|
||||
let tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message(
|
||||
transport.as_ref(),
|
||||
&tool,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction:
|
||||
r#"skill:zhihu_navigate {"page":"content_analysis","ensure_loaded":false}"#
|
||||
.to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: String::new(),
|
||||
page_title: String::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(sent.len(), 3);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info"
|
||||
&& message == "navigate https://www.zhihu.com/creator/analytics/work/all"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
AgentMessage::Command { seq, action, security, .. }
|
||||
if *seq == 1
|
||||
&& action == &Action::Navigate
|
||||
&& security.expected_domain == "www.zhihu.com"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[2],
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success
|
||||
&& summary
|
||||
== "知乎页面已打开:内容分析 (https://www.zhihu.com/creator/analytics/work/all)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natural_language_zhihu_navigation_short_circuits_before_planner_fallback() {
|
||||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: serde_json::json!({ "url": "https://www.zhihu.com/" }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 20,
|
||||
},
|
||||
}]));
|
||||
let tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message(
|
||||
transport.as_ref(),
|
||||
&tool,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "打开知乎首页".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: String::new(),
|
||||
page_title: String::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(sent.len(), 3);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "navigate https://www.zhihu.com/"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
AgentMessage::Command { seq, action, security, .. }
|
||||
if *seq == 1
|
||||
&& action == &Action::Navigate
|
||||
&& security.expected_domain == "www.zhihu.com"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[2],
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary == "知乎页面已打开:首页 (https://www.zhihu.com/)"
|
||||
));
|
||||
}
|
||||
|
||||
441
tests/skill_router_test.rs
Normal file
441
tests/skill_router_test.rs
Normal file
@@ -0,0 +1,441 @@
|
||||
use sgclaw::skill::router::{route_instruction, RoutedSkill, RouterError};
|
||||
|
||||
#[test]
|
||||
fn route_instruction_parses_explicit_zhihu_skill() {
|
||||
let routed = route_instruction(
|
||||
r#"skill:zhihu_write {"title":"自动发文能力测试","body":"第一段\n\n第二段","publish":false}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuWrite(req))
|
||||
if req.title == "自动发文能力测试"
|
||||
&& req.body == "第一段\n\n第二段"
|
||||
&& !req.publish
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_parses_explicit_zhihu_hotlist_collect_skill() {
|
||||
let routed = route_instruction(
|
||||
r#"skill:zhihu_hotlist_collect {"top_n":5,"comments_per_item":8,"store_dir":"data/zhihu_hotlist"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuHotlistCollect(req))
|
||||
if req.top_n == 5
|
||||
&& req.comments_per_item == 8
|
||||
&& req.store_dir.as_deref() == Some("data/zhihu_hotlist")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_parses_explicit_zhihu_hotlist_report_skill() {
|
||||
let routed =
|
||||
route_instruction(r#"skill:zhihu_hotlist_report {"snapshot_id":"snap-1","top_n":3}"#)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuHotlistReport(req))
|
||||
if req.snapshot_id.as_deref() == Some("snap-1")
|
||||
&& req.top_n == 3
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_parses_explicit_zhihu_navigation_skill() {
|
||||
let routed = route_instruction(
|
||||
r#"skill:zhihu_navigate {"page":"content_analysis","ensure_loaded":true}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "content_analysis" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_content_analysis_natural_language() {
|
||||
let routed = route_instruction("帮我打开知乎中的内容分析页面").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "content_analysis" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_short_zhihu_content_analysis_phrase() {
|
||||
let routed = route_instruction("打开知乎内容分析").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "content_analysis" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_income_analysis_natural_language() {
|
||||
let routed = route_instruction("打开知乎收益分析页面").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "income_analysis" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_home_natural_language() {
|
||||
let routed = route_instruction("打开知乎首页").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "home" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_hot_list_natural_language() {
|
||||
let routed = route_instruction("打开知乎热榜页面").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "hot_list" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_column_home_natural_language() {
|
||||
let routed = route_instruction("打开知乎专栏页").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "column_home" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_question_page_natural_language() {
|
||||
let routed = route_instruction("打开知乎问题页").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "question_page" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_messages_page_natural_language() {
|
||||
let routed = route_instruction("打开知乎消息分栏").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "messages_page" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_messages_all_tab_natural_language() {
|
||||
let routed = route_instruction("打开知乎消息分栏全部私信").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "messages_all_tab" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_messages_unread_tab_natural_language() {
|
||||
let routed = route_instruction("打开知乎消息分栏未读消息").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "messages_unread_tab" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_messages_strangers_tab_natural_language() {
|
||||
let routed = route_instruction("打开知乎消息分栏陌生人消息").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "messages_strangers_tab" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_messages_settings_menu_natural_language() {
|
||||
let routed = route_instruction("打开知乎消息设置菜单").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "messages_settings_menu" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_notifications_page_natural_language() {
|
||||
let routed = route_instruction("打开知乎通知分栏").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "notifications_page" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_notifications_replies_tab_natural_language() {
|
||||
let routed = route_instruction("打开知乎通知分栏回复我的").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "notifications_replies_tab" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_notifications_votes_favorites_tab_natural_language() {
|
||||
let routed = route_instruction("打开知乎通知分栏赞同与收藏").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "notifications_votes_favorites_tab" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_notifications_follows_tab_natural_language() {
|
||||
let routed = route_instruction("打开知乎通知分栏关注我的").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "notifications_follows_tab" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_notifications_system_tab_natural_language() {
|
||||
let routed = route_instruction("打开知乎通知分栏系统通知").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "notifications_system_tab" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_notifications_settings_menu_natural_language() {
|
||||
let routed = route_instruction("打开知乎通知设置菜单").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "notifications_settings_menu" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_profile_page_natural_language() {
|
||||
let routed = route_instruction("打开知乎个人主页").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "profile_page" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_profile_answers_tab_natural_language() {
|
||||
let routed = route_instruction("打开知乎个人主页回答分栏").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "profile_answers_tab" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_profile_followers_tab_natural_language() {
|
||||
let routed = route_instruction("打开知乎个人主页粉丝分栏").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "profile_followers_tab" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_account_settings_natural_language() {
|
||||
let routed = route_instruction("打开知乎账号设置菜单").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "settings_account_menu" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_privacy_settings_natural_language() {
|
||||
let routed = route_instruction("打开知乎隐私设置菜单").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "settings_privacy_menu" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_security_settings_natural_language() {
|
||||
let routed = route_instruction("打开知乎安全设置菜单").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "settings_security_menu" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_search_filter_menu_natural_language() {
|
||||
let routed = route_instruction("打开知乎搜索筛选菜单").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "search_filter_menu" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_context_more_menu_natural_language() {
|
||||
let routed = route_instruction("打开知乎更多菜单").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "context_more_menu" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_notifications_menu_natural_language() {
|
||||
let routed = route_instruction("打开知乎通知菜单").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "notifications_menu" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_notifications_entry_natural_language() {
|
||||
let routed = route_instruction("打开知乎通知按钮").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "notifications_entry" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_search_box_natural_language() {
|
||||
let routed = route_instruction("打开知乎搜索框").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "search_box" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_creator_write_button_natural_language() {
|
||||
let routed = route_instruction("打开知乎创作中心写文章按钮").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "creator_write_button" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_open_hot_from_home_flow_natural_language() {
|
||||
let routed = route_instruction("从知乎首页进入热榜").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "open_hot_from_home" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_returns_none_for_non_skill_text() {
|
||||
let routed = route_instruction("打开百度搜索天气").unwrap();
|
||||
|
||||
assert!(routed.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_returns_none_for_vague_zhihu_navigation_text() {
|
||||
let routed = route_instruction("打开知乎").unwrap();
|
||||
|
||||
assert!(routed.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_returns_none_for_ambiguous_zhihu_notification_phrase() {
|
||||
let routed = route_instruction("打开知乎通知").unwrap();
|
||||
|
||||
assert!(routed.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_routes_zhihu_hot_button_phrase_to_hot_tab() {
|
||||
let routed = route_instruction("打开知乎热榜按钮").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
routed,
|
||||
Some(RoutedSkill::ZhihuNavigate(req))
|
||||
if req.page == "hot_tab" && req.ensure_loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_instruction_rejects_unknown_skill_name() {
|
||||
let err = route_instruction(r#"skill:unknown {"x":1}"#).unwrap_err();
|
||||
|
||||
assert!(matches!(err, RouterError::UnknownSkill(name) if name == "unknown"));
|
||||
}
|
||||
403
tests/zhihu_hotlist_skill_test.rs
Normal file
403
tests/zhihu_hotlist_skill_test.rs
Normal file
@@ -0,0 +1,403 @@
|
||||
mod common;
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use common::MockTransport;
|
||||
use sgclaw::pipe::{BrowserMessage, BrowserPipeTool, Timing};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use sgclaw::skill::zhihu_hotlist::{
|
||||
execute_collect, execute_report, load_flow, ZhihuHotlistCollectRequest,
|
||||
ZhihuHotlistReportRequest,
|
||||
};
|
||||
use sgclaw::skill::zhihu_hotlist_store::load_latest_snapshot;
|
||||
|
||||
fn test_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.zhihu.com", "zhuanlan.zhihu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText", "getHtml", "waitForSelector", "scrollTo"],
|
||||
"blocked": []
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn temp_store_dir(label: &str) -> PathBuf {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("sgclaw-{label}-{unique}"))
|
||||
}
|
||||
|
||||
fn hotlist_html() -> String {
|
||||
r#"
|
||||
<html>
|
||||
<body>
|
||||
<main>
|
||||
<section data-hot-item>
|
||||
<h2><a href="/question/123">第一条热榜</a></h2>
|
||||
<div class="HotItem-content">第一条摘要</div>
|
||||
<div class="HotItem-hot">1234 热度</div>
|
||||
</section>
|
||||
<section data-hot-item>
|
||||
<h2><a href="/question/456">第二条热榜</a></h2>
|
||||
<div class="HotItem-content">第二条摘要</div>
|
||||
<div class="HotItem-hot">5.6 万热度</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn comment_html(
|
||||
first_reply: u64,
|
||||
first_upvote: u64,
|
||||
second_reply: u64,
|
||||
second_upvote: u64,
|
||||
) -> String {
|
||||
format!(
|
||||
r#"
|
||||
<html>
|
||||
<body>
|
||||
<div class="CommentListV2">
|
||||
<div class="CommentItemV2" data-comment-id="comment-1">
|
||||
<button>回复 {first_reply}</button>
|
||||
<button>赞同 {first_upvote}</button>
|
||||
<button>收藏 2</button>
|
||||
<button>红心 1</button>
|
||||
</div>
|
||||
<div class="CommentItemV2" data-comment-id="comment-2">
|
||||
<button>回复 {second_reply}</button>
|
||||
<button>赞同 {second_upvote}</button>
|
||||
<button>收藏 4</button>
|
||||
<button>红心 3</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hotlist_flow_preserves_expected_selectors() {
|
||||
let flow = load_flow().unwrap();
|
||||
|
||||
assert_eq!(flow.hotlist_url, "https://www.zhihu.com/hot");
|
||||
assert_eq!(flow.domains["zhihu"], "www.zhihu.com");
|
||||
assert!(flow.selectors["hotlist_item"].contains("HotList-item"));
|
||||
assert!(flow.selectors["comment_metric"].contains("button"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_hotlist_collect_persists_snapshot_and_report_reads_latest() {
|
||||
let store_dir = temp_store_dir("hotlist-collect");
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: serde_json::json!({ "text": "知乎热榜 当前页", "url": "https://www.zhihu.com/hot" }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 2,
|
||||
success: true,
|
||||
data: serde_json::json!({ "ready": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 3,
|
||||
success: true,
|
||||
data: serde_json::json!({ "html": hotlist_html(), "url": "https://www.zhihu.com/hot" }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 4,
|
||||
success: true,
|
||||
data: serde_json::json!({ "url": "https://www.zhihu.com/question/123" }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 5,
|
||||
success: true,
|
||||
data: serde_json::json!({ "ready": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 6,
|
||||
success: true,
|
||||
data: serde_json::json!({ "scrolled": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 7,
|
||||
success: true,
|
||||
data: serde_json::json!({ "ready": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 8,
|
||||
success: true,
|
||||
data: serde_json::json!({ "scrolled": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 9,
|
||||
success: true,
|
||||
data: serde_json::json!({ "html": comment_html(3, 15, 1, 8), "url": "https://www.zhihu.com/question/123" }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 10,
|
||||
success: true,
|
||||
data: serde_json::json!({ "url": "https://www.zhihu.com/question/456" }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 11,
|
||||
success: true,
|
||||
data: serde_json::json!({ "ready": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 12,
|
||||
success: true,
|
||||
data: serde_json::json!({ "scrolled": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 13,
|
||||
success: true,
|
||||
data: serde_json::json!({ "ready": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 14,
|
||||
success: true,
|
||||
data: serde_json::json!({ "scrolled": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 15,
|
||||
success: true,
|
||||
data: serde_json::json!({ "html": comment_html(5, 20, 4, 16), "url": "https://www.zhihu.com/question/456" }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
]));
|
||||
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 result = execute_collect(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuHotlistCollectRequest {
|
||||
top_n: 2,
|
||||
comments_per_item: 2,
|
||||
store_dir: Some(store_dir.display().to_string()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.item_count, 2);
|
||||
assert!(result.summary.contains("知乎热榜快照已保存"));
|
||||
|
||||
let snapshot = load_latest_snapshot(&store_dir).unwrap();
|
||||
assert_eq!(snapshot.items.len(), 2);
|
||||
assert_eq!(snapshot.items[0].title, "第一条热榜");
|
||||
assert_eq!(snapshot.items[0].summary, "第一条摘要");
|
||||
assert_eq!(snapshot.items[0].heat_value, Some(1234));
|
||||
assert_eq!(snapshot.items[0].comment_metrics.len(), 2);
|
||||
assert_eq!(snapshot.items[0].comment_metrics[0].reply_count, Some(3));
|
||||
assert_eq!(snapshot.items[0].comment_metrics[0].upvote_count, Some(15));
|
||||
assert_eq!(snapshot.items[1].heat_value, Some(56_000));
|
||||
assert_eq!(snapshot.collection_stats.total_comment_metric_records, 4);
|
||||
|
||||
let report = execute_report(ZhihuHotlistReportRequest {
|
||||
snapshot_id: Some(result.snapshot_id.clone()),
|
||||
store_dir: Some(store_dir.display().to_string()),
|
||||
top_n: 2,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(report.summary.contains("第一条热榜"));
|
||||
assert!(report.summary.contains("第二条热榜"));
|
||||
assert!(report.summary.contains("回复 4"));
|
||||
assert!(report.summary.contains("赞同 23"));
|
||||
|
||||
let _ = fs::remove_dir_all(&store_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_hotlist_collect_persists_partial_snapshot_when_comment_capture_fails() {
|
||||
let store_dir = temp_store_dir("hotlist-partial");
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: serde_json::json!({ "text": "知乎热榜 当前页", "url": "https://www.zhihu.com/hot" }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 2,
|
||||
success: true,
|
||||
data: serde_json::json!({ "ready": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 3,
|
||||
success: true,
|
||||
data: serde_json::json!({ "html": hotlist_html(), "url": "https://www.zhihu.com/hot" }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 4,
|
||||
success: true,
|
||||
data: serde_json::json!({ "url": "https://www.zhihu.com/question/123" }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 5,
|
||||
success: true,
|
||||
data: serde_json::json!({ "ready": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 6,
|
||||
success: true,
|
||||
data: serde_json::json!({ "scrolled": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 7,
|
||||
success: false,
|
||||
data: serde_json::json!({ "error": "comment list missing" }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
]));
|
||||
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 result = execute_collect(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuHotlistCollectRequest {
|
||||
top_n: 1,
|
||||
comments_per_item: 2,
|
||||
store_dir: Some(store_dir.display().to_string()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let snapshot = load_latest_snapshot(&store_dir).unwrap();
|
||||
assert_eq!(result.item_count, 1);
|
||||
assert_eq!(snapshot.collection_stats.partial_items, 1);
|
||||
assert_eq!(snapshot.collection_stats.total_comment_metric_records, 0);
|
||||
assert!(snapshot.items[0].comment_metrics.is_empty());
|
||||
|
||||
let _ = fs::remove_dir_all(&store_dir);
|
||||
}
|
||||
661
tests/zhihu_navigation_skill_test.rs
Normal file
661
tests/zhihu_navigation_skill_test.rs
Normal file
@@ -0,0 +1,661 @@
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::MockTransport;
|
||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use sgclaw::skill::zhihu_navigation::{execute, load_catalog, ZhihuNavigateRequest};
|
||||
|
||||
fn test_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.zhihu.com", "zhuanlan.zhihu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "navigate", "getText", "waitForSelector"],
|
||||
"blocked": []
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn response(seq: u64, data: serde_json::Value) -> BrowserMessage {
|
||||
BrowserMessage::Response {
|
||||
seq,
|
||||
success: true,
|
||||
data,
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_catalog_preserves_confirmed_content_analysis_route() {
|
||||
let catalog = load_catalog().unwrap();
|
||||
|
||||
assert_eq!(catalog.domains["creator"], "www.zhihu.com");
|
||||
assert_eq!(
|
||||
catalog.routes["content_analysis"].url,
|
||||
"https://www.zhihu.com/creator/analytics/work/all"
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.targets["content_analysis"].route_ref.as_deref(),
|
||||
Some("content_analysis")
|
||||
);
|
||||
assert!(catalog.routes["content_analysis"]
|
||||
.aliases
|
||||
.iter()
|
||||
.any(|alias| alias == "知乎内容分析页面"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_catalog_includes_top_level_navigation_targets() {
|
||||
let catalog = load_catalog().unwrap();
|
||||
|
||||
assert_eq!(catalog.routes["home"].url, "https://www.zhihu.com/");
|
||||
assert_eq!(catalog.routes["hot_list"].url, "https://www.zhihu.com/hot");
|
||||
assert_eq!(
|
||||
catalog.routes["column_home"].url,
|
||||
"https://zhuanlan.zhihu.com/"
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.routes["messages_page"].url,
|
||||
"https://www.zhihu.com/messages"
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.routes["notifications_page"].url,
|
||||
"https://www.zhihu.com/notifications"
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.targets["messages_unread_tab"]
|
||||
.component_ref
|
||||
.as_deref(),
|
||||
Some("messages_tab_unread")
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.targets["notifications_replies_tab"]
|
||||
.component_ref
|
||||
.as_deref(),
|
||||
Some("notifications_tab_replies")
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.targets["notifications_settings_menu"]
|
||||
.component_ref
|
||||
.as_deref(),
|
||||
Some("notifications_settings_menu")
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.targets["profile_page"].flow_ref.as_deref(),
|
||||
Some("open_profile_from_avatar_menu")
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.targets["notifications_menu"].flow_ref.as_deref(),
|
||||
Some("open_notifications_menu")
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.targets["search_box"].component_ref.as_deref(),
|
||||
Some("top_nav_search")
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.components["creator_write_button"]
|
||||
.result_domain_ref
|
||||
.as_deref(),
|
||||
Some("editor")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_catalog_includes_expanded_profile_and_settings_flows() {
|
||||
let catalog = load_catalog().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
catalog.targets["profile_answers_tab"].flow_ref.as_deref(),
|
||||
Some("open_profile_answers_tab")
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.targets["profile_followers_tab"].flow_ref.as_deref(),
|
||||
Some("open_profile_followers_tab")
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.targets["settings_account_menu"].flow_ref.as_deref(),
|
||||
Some("open_account_settings_from_avatar_menu")
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.targets["settings_privacy_menu"].flow_ref.as_deref(),
|
||||
Some("open_privacy_settings_from_avatar_menu")
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.targets["settings_security_menu"]
|
||||
.flow_ref
|
||||
.as_deref(),
|
||||
Some("open_security_settings_from_avatar_menu")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_navigation_skill_opens_content_analysis_page() {
|
||||
let transport = Arc::new(MockTransport::new(vec![response(
|
||||
1,
|
||||
serde_json::json!({ "url": "https://www.zhihu.com/creator/analytics/work/all" }),
|
||||
)]));
|
||||
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 result = execute(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuNavigateRequest {
|
||||
page: "content_analysis".to_string(),
|
||||
ensure_loaded: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(
|
||||
result.summary,
|
||||
"知乎页面已打开:内容分析 (https://www.zhihu.com/creator/analytics/work/all)"
|
||||
);
|
||||
assert_eq!(result.page, "content_analysis");
|
||||
assert_eq!(
|
||||
result.final_url,
|
||||
"https://www.zhihu.com/creator/analytics/work/all"
|
||||
);
|
||||
assert_eq!(sent.len(), 2);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info"
|
||||
&& message == "navigate https://www.zhihu.com/creator/analytics/work/all"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 1 && action == &Action::Navigate
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_navigation_skill_clicks_creator_write_button() {
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
response(
|
||||
1,
|
||||
serde_json::json!({ "url": "https://www.zhihu.com/creator" }),
|
||||
),
|
||||
response(
|
||||
2,
|
||||
serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
response(
|
||||
3,
|
||||
serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
]));
|
||||
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 result = execute(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuNavigateRequest {
|
||||
page: "creator_write_button".to_string(),
|
||||
ensure_loaded: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(
|
||||
result.summary,
|
||||
"知乎入口已打开:写文章入口按钮 (https://zhuanlan.zhihu.com/write)"
|
||||
);
|
||||
assert_eq!(result.page, "creator_write_button");
|
||||
assert_eq!(result.final_url, "https://zhuanlan.zhihu.com/write");
|
||||
assert_eq!(sent.len(), 6);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "navigate https://www.zhihu.com/creator"
|
||||
));
|
||||
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 == "click 写文章入口按钮"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[3],
|
||||
AgentMessage::Command { seq, action, security, .. }
|
||||
if *seq == 2 && action == &Action::Click && security.expected_domain == "www.zhihu.com"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[4],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message.contains("wait for textarea")
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[5],
|
||||
AgentMessage::Command { seq, action, security, .. }
|
||||
if *seq == 3 && action == &Action::WaitForSelector && security.expected_domain == "zhuanlan.zhihu.com"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_navigation_skill_opens_notifications_menu_flow() {
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
response(1, serde_json::json!({ "url": "https://www.zhihu.com/" })),
|
||||
response(2, serde_json::json!({ "clicked": true })),
|
||||
]));
|
||||
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 result = execute(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuNavigateRequest {
|
||||
page: "notifications_menu".to_string(),
|
||||
ensure_loaded: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(result.summary, "知乎菜单已打开:通知菜单");
|
||||
assert_eq!(result.page, "notifications_menu");
|
||||
assert_eq!(result.final_url, "https://www.zhihu.com/");
|
||||
assert_eq!(sent.len(), 4);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "navigate https://www.zhihu.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 == "click 通知菜单"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[3],
|
||||
AgentMessage::Command { seq, action, security, .. }
|
||||
if *seq == 2 && action == &Action::Click && security.expected_domain == "www.zhihu.com"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_navigation_skill_opens_profile_page_from_avatar_menu() {
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
response(1, serde_json::json!({ "url": "https://www.zhihu.com/" })),
|
||||
response(2, serde_json::json!({ "clicked": true })),
|
||||
response(
|
||||
3,
|
||||
serde_json::json!({ "clicked": true, "url": "https://www.zhihu.com/people/test-user" }),
|
||||
),
|
||||
]));
|
||||
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 result = execute(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuNavigateRequest {
|
||||
page: "profile_page".to_string(),
|
||||
ensure_loaded: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(
|
||||
result.summary,
|
||||
"知乎导航已完成:个人主页 (https://www.zhihu.com/people/test-user)"
|
||||
);
|
||||
assert_eq!(result.page, "profile_page");
|
||||
assert_eq!(result.final_url, "https://www.zhihu.com/people/test-user");
|
||||
assert_eq!(sent.len(), 6);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "navigate https://www.zhihu.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 == "click 头像菜单"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[3],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 2 && action == &Action::Click
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[4],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "click 个人主页入口"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[5],
|
||||
AgentMessage::Command { seq, action, security, .. }
|
||||
if *seq == 3 && action == &Action::Click && security.expected_domain == "www.zhihu.com"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_navigation_skill_opens_profile_answers_tab_from_avatar_menu() {
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
response(1, serde_json::json!({ "url": "https://www.zhihu.com/" })),
|
||||
response(2, serde_json::json!({ "clicked": true })),
|
||||
response(
|
||||
3,
|
||||
serde_json::json!({ "clicked": true, "url": "https://www.zhihu.com/people/test-user" }),
|
||||
),
|
||||
response(
|
||||
4,
|
||||
serde_json::json!({ "clicked": true, "url": "https://www.zhihu.com/people/test-user/answers" }),
|
||||
),
|
||||
]));
|
||||
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 result = execute(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuNavigateRequest {
|
||||
page: "profile_answers_tab".to_string(),
|
||||
ensure_loaded: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(
|
||||
result.summary,
|
||||
"知乎导航已完成:回答分栏 (https://www.zhihu.com/people/test-user/answers)"
|
||||
);
|
||||
assert_eq!(result.page, "profile_answers_tab");
|
||||
assert_eq!(
|
||||
result.final_url,
|
||||
"https://www.zhihu.com/people/test-user/answers"
|
||||
);
|
||||
assert_eq!(sent.len(), 8);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "navigate https://www.zhihu.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 == "click 头像菜单"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[3],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 2 && action == &Action::Click
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[4],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "click 个人主页入口"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[5],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 3 && action == &Action::Click
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[6],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "click 回答分栏"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[7],
|
||||
AgentMessage::Command { seq, action, security, .. }
|
||||
if *seq == 4 && action == &Action::Click && security.expected_domain == "www.zhihu.com"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_navigation_skill_opens_account_settings_from_avatar_menu() {
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
response(1, serde_json::json!({ "url": "https://www.zhihu.com/" })),
|
||||
response(2, serde_json::json!({ "clicked": true })),
|
||||
response(
|
||||
3,
|
||||
serde_json::json!({ "clicked": true, "url": "https://www.zhihu.com/settings/account" }),
|
||||
),
|
||||
]));
|
||||
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 result = execute(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuNavigateRequest {
|
||||
page: "settings_account_menu".to_string(),
|
||||
ensure_loaded: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(
|
||||
result.summary,
|
||||
"知乎导航已完成:账号设置菜单 (https://www.zhihu.com/settings/account)"
|
||||
);
|
||||
assert_eq!(result.page, "settings_account_menu");
|
||||
assert_eq!(result.final_url, "https://www.zhihu.com/settings/account");
|
||||
assert_eq!(sent.len(), 6);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "navigate https://www.zhihu.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 == "click 头像菜单"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[3],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 2 && action == &Action::Click
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[4],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "click 账号设置菜单"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[5],
|
||||
AgentMessage::Command { seq, action, security, .. }
|
||||
if *seq == 3 && action == &Action::Click && security.expected_domain == "www.zhihu.com"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_navigation_skill_opens_notifications_replies_tab() {
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
response(
|
||||
1,
|
||||
serde_json::json!({ "url": "https://www.zhihu.com/notifications" }),
|
||||
),
|
||||
response(
|
||||
2,
|
||||
serde_json::json!({ "clicked": true, "url": "https://www.zhihu.com/notifications/replies" }),
|
||||
),
|
||||
]));
|
||||
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 result = execute(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuNavigateRequest {
|
||||
page: "notifications_replies_tab".to_string(),
|
||||
ensure_loaded: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(
|
||||
result.summary,
|
||||
"知乎入口已打开:回复我的 (https://www.zhihu.com/notifications/replies)"
|
||||
);
|
||||
assert_eq!(sent.len(), 4);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "navigate https://www.zhihu.com/notifications"
|
||||
));
|
||||
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 == "click 回复我的"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[3],
|
||||
AgentMessage::Command { seq, action, security, .. }
|
||||
if *seq == 2 && action == &Action::Click && security.expected_domain == "www.zhihu.com"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_navigation_skill_opens_messages_settings_menu() {
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
response(
|
||||
1,
|
||||
serde_json::json!({ "url": "https://www.zhihu.com/messages" }),
|
||||
),
|
||||
response(2, serde_json::json!({ "clicked": true })),
|
||||
]));
|
||||
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 result = execute(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuNavigateRequest {
|
||||
page: "messages_settings_menu".to_string(),
|
||||
ensure_loaded: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(result.summary, "知乎菜单已打开:消息设置菜单");
|
||||
assert_eq!(result.final_url, "https://www.zhihu.com/messages");
|
||||
assert_eq!(sent.len(), 4);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "navigate https://www.zhihu.com/messages"
|
||||
));
|
||||
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 == "click 消息设置菜单"
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[3],
|
||||
AgentMessage::Command { seq, action, security, .. }
|
||||
if *seq == 2 && action == &Action::Click && security.expected_domain == "www.zhihu.com"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_navigation_skill_rejects_unknown_target() {
|
||||
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 err = execute(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuNavigateRequest {
|
||||
page: "unknown_target".to_string(),
|
||||
ensure_loaded: true,
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("unknown zhihu target: unknown_target"));
|
||||
}
|
||||
357
tests/zhihu_skill_test.rs
Normal file
357
tests/zhihu_skill_test.rs
Normal file
@@ -0,0 +1,357 @@
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::MockTransport;
|
||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use sgclaw::skill::zhihu::{execute, load_flow, ZhihuWriteRequest};
|
||||
|
||||
fn test_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.zhihu.com", "zhuanlan.zhihu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText", "getHtml", "waitForSelector", "scrollTo"],
|
||||
"blocked": []
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn response(seq: u64, data: serde_json::Value) -> BrowserMessage {
|
||||
BrowserMessage::Response {
|
||||
seq,
|
||||
success: true,
|
||||
data,
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_flow_preserves_validated_zhihu_literals() {
|
||||
let flow = load_flow().unwrap();
|
||||
|
||||
assert_eq!(flow.entry_url, "https://www.zhihu.com/creator");
|
||||
assert_eq!(flow.editor_url, "https://zhuanlan.zhihu.com/write");
|
||||
assert_eq!(flow.literals["write_entry_text"], "写文章");
|
||||
assert_eq!(flow.literals["publish_confirm_text"], "确认发布");
|
||||
assert_eq!(
|
||||
flow.literals["title_placeholder"],
|
||||
"请输入标题(最多 100 个字)"
|
||||
);
|
||||
assert_eq!(
|
||||
flow.selectors["creator_write_entry"],
|
||||
"div.css-1q62b6s > div.css-byu4by"
|
||||
);
|
||||
assert_eq!(
|
||||
flow.selectors["publish_confirm_button"],
|
||||
"div[role='dialog'] button.Button--primary.Button--blue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_skill_stops_before_publish_when_publish_is_false() {
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
response(
|
||||
1,
|
||||
serde_json::json!({ "url": "https://www.zhihu.com/creator" }),
|
||||
),
|
||||
response(
|
||||
2,
|
||||
serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
response(
|
||||
3,
|
||||
serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
response(4, serde_json::json!({ "typed": true })),
|
||||
response(5, serde_json::json!({ "typed": true })),
|
||||
]));
|
||||
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 result = execute(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuWriteRequest {
|
||||
title: "自动发文能力测试".to_string(),
|
||||
body: "第一段\n\n第二段".to_string(),
|
||||
publish: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(result.summary, "知乎文章草稿已填充:自动发文能力测试");
|
||||
assert_eq!(sent.len(), 10);
|
||||
assert!(matches!(
|
||||
&sent[5],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 3 && action == &Action::WaitForSelector
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[9],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 5 && action == &Action::Type
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_skill_publishes_only_after_confirming_dialog_title_and_final_url() {
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
response(
|
||||
1,
|
||||
serde_json::json!({ "url": "https://www.zhihu.com/creator" }),
|
||||
),
|
||||
response(
|
||||
2,
|
||||
serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
response(
|
||||
3,
|
||||
serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
response(4, serde_json::json!({ "typed": true })),
|
||||
response(5, serde_json::json!({ "typed": true })),
|
||||
response(6, serde_json::json!({ "scrolled": true })),
|
||||
response(
|
||||
7,
|
||||
serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
response(8, serde_json::json!({ "ready": true })),
|
||||
response(
|
||||
9,
|
||||
serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/p/123456" }),
|
||||
),
|
||||
response(
|
||||
10,
|
||||
serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/p/123456" }),
|
||||
),
|
||||
response(
|
||||
11,
|
||||
serde_json::json!({ "text": "自动发文能力测试", "url": "https://zhuanlan.zhihu.com/p/123456" }),
|
||||
),
|
||||
]));
|
||||
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 result = execute(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuWriteRequest {
|
||||
title: "自动发文能力测试".to_string(),
|
||||
body: "第一段\n\n第二段".to_string(),
|
||||
publish: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(
|
||||
result.summary,
|
||||
"知乎文章已发布:自动发文能力测试 (https://zhuanlan.zhihu.com/p/123456)"
|
||||
);
|
||||
assert_eq!(
|
||||
result.final_url.as_deref(),
|
||||
Some("https://zhuanlan.zhihu.com/p/123456")
|
||||
);
|
||||
assert!(result.published);
|
||||
assert_eq!(sent.len(), 22);
|
||||
assert!(matches!(
|
||||
&sent[11],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 6 && action == &Action::ScrollTo
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[21],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 11 && action == &Action::GetText
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_skill_accepts_edit_url_as_published_article_url() {
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
response(
|
||||
1,
|
||||
serde_json::json!({ "url": "https://www.zhihu.com/creator" }),
|
||||
),
|
||||
response(
|
||||
2,
|
||||
serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
response(
|
||||
3,
|
||||
serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
response(4, serde_json::json!({ "typed": true })),
|
||||
response(5, serde_json::json!({ "typed": true })),
|
||||
response(6, serde_json::json!({ "scrolled": true })),
|
||||
response(
|
||||
7,
|
||||
serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
response(8, serde_json::json!({ "ready": true })),
|
||||
response(
|
||||
9,
|
||||
serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/p/123456/edit" }),
|
||||
),
|
||||
response(
|
||||
10,
|
||||
serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/p/123456/edit" }),
|
||||
),
|
||||
response(
|
||||
11,
|
||||
serde_json::json!({ "text": "自动发文能力测试", "url": "https://zhuanlan.zhihu.com/p/123456/edit" }),
|
||||
),
|
||||
]));
|
||||
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 result = execute(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuWriteRequest {
|
||||
title: "自动发文能力测试".to_string(),
|
||||
body: "第一段\n\n第二段".to_string(),
|
||||
publish: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result.final_url.as_deref(),
|
||||
Some("https://zhuanlan.zhihu.com/p/123456")
|
||||
);
|
||||
assert_eq!(
|
||||
result.summary,
|
||||
"知乎文章已发布:自动发文能力测试 (https://zhuanlan.zhihu.com/p/123456)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_skill_fails_when_publish_confirmation_never_returns_article_url() {
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
response(
|
||||
1,
|
||||
serde_json::json!({ "url": "https://www.zhihu.com/creator" }),
|
||||
),
|
||||
response(
|
||||
2,
|
||||
serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
response(
|
||||
3,
|
||||
serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
response(4, serde_json::json!({ "typed": true })),
|
||||
response(5, serde_json::json!({ "typed": true })),
|
||||
response(6, serde_json::json!({ "scrolled": true })),
|
||||
response(
|
||||
7,
|
||||
serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
response(8, serde_json::json!({ "ready": true })),
|
||||
response(9, serde_json::json!({ "clicked": true })),
|
||||
response(10, serde_json::json!({ "ready": true })),
|
||||
response(11, serde_json::json!({ "text": "自动发文能力测试" })),
|
||||
]));
|
||||
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 err = execute(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuWriteRequest {
|
||||
title: "自动发文能力测试".to_string(),
|
||||
body: "第一段\n\n第二段".to_string(),
|
||||
publish: true,
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("did not return article url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_skill_fails_when_published_title_does_not_match_request_title() {
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
response(
|
||||
1,
|
||||
serde_json::json!({ "url": "https://www.zhihu.com/creator" }),
|
||||
),
|
||||
response(
|
||||
2,
|
||||
serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
response(
|
||||
3,
|
||||
serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
response(4, serde_json::json!({ "typed": true })),
|
||||
response(5, serde_json::json!({ "typed": true })),
|
||||
response(6, serde_json::json!({ "scrolled": true })),
|
||||
response(
|
||||
7,
|
||||
serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }),
|
||||
),
|
||||
response(8, serde_json::json!({ "ready": true })),
|
||||
response(
|
||||
9,
|
||||
serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/p/123456" }),
|
||||
),
|
||||
response(
|
||||
10,
|
||||
serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/p/123456" }),
|
||||
),
|
||||
response(
|
||||
11,
|
||||
serde_json::json!({ "text": "别的标题", "url": "https://zhuanlan.zhihu.com/p/123456" }),
|
||||
),
|
||||
]));
|
||||
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 err = execute(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
ZhihuWriteRequest {
|
||||
title: "自动发文能力测试".to_string(),
|
||||
body: "第一段\n\n第二段".to_string(),
|
||||
publish: true,
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("expected text `自动发文能力测试`, got `别的标题`"));
|
||||
}
|
||||
Reference in New Issue
Block a user