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:
木炎
2026-03-27 14:29:38 +08:00
parent b87968632a
commit 6aad2ce48e
32 changed files with 7607 additions and 146 deletions

View File

@@ -17,6 +17,7 @@ impl MockTransport {
}
}
#[allow(dead_code)]
pub fn sent_messages(&self) -> Vec<AgentMessage> {
self.sent.lock().unwrap().clone()
}

View File

@@ -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"));
}

View File

@@ -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]

View File

@@ -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!("已在百度搜索天气")
}));
}

View File

@@ -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");
}

View File

@@ -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
View 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"));
}

View 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);
}

View 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
View 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 `别的标题`"));
}