sgclaw: snapshot today's runtime and skill updates
This commit is contained in:
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::MockTransport;
|
||||
use sgclaw::agent::handle_browser_message;
|
||||
use sgclaw::agent::runtime::{browser_action_tool_definition, execute_task_with_provider};
|
||||
use sgclaw::llm::{ChatMessage, LlmError, LlmProvider, ToolDefinition, ToolFunctionCall};
|
||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
@@ -132,3 +133,46 @@ fn runtime_executes_provider_tool_calls_and_returns_summary() {
|
||||
if *seq == 2 && action == &Action::Type
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_agent_runtime_is_explicitly_dev_only() {
|
||||
assert!(sgclaw::agent::runtime::LEGACY_DEV_ONLY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_submit_task_does_not_route_into_legacy_runtime_without_llm_config() {
|
||||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||
std::env::remove_var("DEEPSEEK_MODEL");
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = 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(),
|
||||
&browser_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!(matches!(
|
||||
sent.last(),
|
||||
Some(AgentMessage::TaskComplete { success, summary })
|
||||
if !success && summary.contains("未配置大语言模型")
|
||||
));
|
||||
assert!(!sent
|
||||
.iter()
|
||||
.any(|message| { matches!(message, AgentMessage::Command { .. }) }));
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::fs;
|
||||
|
||||
use common::MockTransport;
|
||||
use serde_json::json;
|
||||
@@ -77,13 +77,8 @@ return {
|
||||
command: "scripts/extract_hotlist.js".to_string(),
|
||||
args,
|
||||
};
|
||||
let tool = BrowserScriptSkillTool::new(
|
||||
"zhihu-hotlist",
|
||||
&skill_tool,
|
||||
&skill_dir,
|
||||
browser_tool,
|
||||
)
|
||||
.unwrap();
|
||||
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_dir, browser_tool)
|
||||
.unwrap();
|
||||
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
|
||||
@@ -96,8 +96,14 @@ fn browser_tool_exposes_privileged_surface_metadata_backed_by_mac_policy() {
|
||||
assert!(metadata.privileged);
|
||||
assert!(!metadata.defines_runtime_identity);
|
||||
assert_eq!(metadata.guard, "mac_policy");
|
||||
assert_eq!(metadata.allowed_domains, vec!["oa.example.com", "erp.example.com"]);
|
||||
assert_eq!(metadata.allowed_actions, vec!["click", "type", "navigate", "getText"]);
|
||||
assert_eq!(
|
||||
metadata.allowed_domains,
|
||||
vec!["oa.example.com", "erp.example.com"]
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.allowed_actions,
|
||||
vec!["click", "type", "navigate", "getText"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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(),
|
||||
@@ -204,13 +206,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]
|
||||
@@ -300,25 +300,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,20 +3,12 @@ use std::path::Path;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use sgclaw::compat::config_adapter::{
|
||||
build_zeroclaw_config,
|
||||
build_zeroclaw_config_from_sgclaw_settings,
|
||||
build_zeroclaw_config_from_settings,
|
||||
resolve_skills_dir,
|
||||
zeroclaw_default_skills_dir,
|
||||
build_zeroclaw_config, build_zeroclaw_config_from_settings,
|
||||
build_zeroclaw_config_from_sgclaw_settings, resolve_skills_dir, zeroclaw_default_skills_dir,
|
||||
zeroclaw_workspace_dir,
|
||||
};
|
||||
use sgclaw::config::{
|
||||
BrowserBackend,
|
||||
DeepSeekSettings,
|
||||
OfficeBackend,
|
||||
PlannerMode,
|
||||
SgClawSettings,
|
||||
SkillsPromptMode,
|
||||
BrowserBackend, DeepSeekSettings, OfficeBackend, PlannerMode, SgClawSettings, SkillsPromptMode,
|
||||
};
|
||||
use sgclaw::runtime::RuntimeProfile;
|
||||
use uuid::Uuid;
|
||||
@@ -61,11 +53,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")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_skills_dir(Path::new("/var/lib/sgclaw"), &settings),
|
||||
zeroclaw_default_skills_dir(Path::new("/var/lib/sgclaw"))
|
||||
@@ -252,7 +250,10 @@ fn sgclaw_settings_load_provider_switching_and_backend_policy_from_browser_confi
|
||||
assert_eq!(settings.planner_mode, PlannerMode::ZeroclawPlanFirst);
|
||||
assert_eq!(settings.active_provider, "glm-prod");
|
||||
assert_eq!(settings.providers.len(), 2);
|
||||
assert_eq!(settings.provider_base_url, "https://open.bigmodel.cn/api/paas/v4");
|
||||
assert_eq!(
|
||||
settings.provider_base_url,
|
||||
"https://open.bigmodel.cn/api/paas/v4"
|
||||
);
|
||||
assert_eq!(settings.provider_model, "glm-4.5");
|
||||
assert_eq!(settings.browser_backend, BrowserBackend::SuperRpa);
|
||||
assert_eq!(settings.office_backend, OfficeBackend::OpenXml);
|
||||
|
||||
@@ -17,6 +17,7 @@ async fn compat_cron_adapter_creates_lists_and_runs_due_agent_jobs() {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: None,
|
||||
};
|
||||
let workspace_root = workspace_root("sgclaw-cron");
|
||||
let config = build_zeroclaw_config_from_settings(Path::new(&workspace_root), &settings);
|
||||
|
||||
@@ -16,6 +16,7 @@ async fn compat_memory_adapter_uses_workspace_local_sqlite_backend() {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: None,
|
||||
};
|
||||
let workspace_root = workspace_root("sgclaw-memory");
|
||||
let config = build_zeroclaw_config_from_settings(Path::new(&workspace_root), &settings);
|
||||
|
||||
@@ -11,15 +11,9 @@ use std::time::Duration;
|
||||
use common::MockTransport;
|
||||
use serde_json::{json, Value};
|
||||
use sgclaw::agent::{
|
||||
handle_browser_message,
|
||||
handle_browser_message_with_context,
|
||||
AgentRuntimeContext,
|
||||
};
|
||||
use sgclaw::compat::runtime::{
|
||||
execute_task,
|
||||
execute_task_with_sgclaw_settings,
|
||||
CompatTaskContext,
|
||||
handle_browser_message, handle_browser_message_with_context, AgentRuntimeContext,
|
||||
};
|
||||
use sgclaw::compat::runtime::{execute_task, execute_task_with_sgclaw_settings, CompatTaskContext};
|
||||
use sgclaw::config::{DeepSeekSettings, SgClawSettings};
|
||||
use sgclaw::pipe::{
|
||||
Action, AgentMessage, BrowserMessage, BrowserPipeTool, ConversationMessage, Timing,
|
||||
@@ -151,8 +145,8 @@ fn tool_message_content<'a>(request: &'a Value, tool_call_id: &str) -> Option<&'
|
||||
messages.iter().find_map(|message| {
|
||||
(message["role"].as_str() == Some("tool")
|
||||
&& message["tool_call_id"].as_str() == Some(tool_call_id))
|
||||
.then(|| message["content"].as_str())
|
||||
.flatten()
|
||||
.then(|| message["content"].as_str())
|
||||
.flatten()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -232,6 +226,23 @@ fn read_http_json_body(stream: &mut impl Read) -> Value {
|
||||
serde_json::from_slice(&buffer[headers_end..headers_end + content_length]).unwrap()
|
||||
}
|
||||
|
||||
fn task_complete_summary(sent: &[AgentMessage]) -> String {
|
||||
sent.iter()
|
||||
.find_map(|message| match message {
|
||||
AgentMessage::TaskComplete { success, summary } if *success => Some(summary.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("expected successful task completion")
|
||||
}
|
||||
|
||||
fn extract_generated_artifact_path(summary: &str, extension: &str) -> PathBuf {
|
||||
summary
|
||||
.split_whitespace()
|
||||
.find(|token| token.ends_with(extension))
|
||||
.map(PathBuf::from)
|
||||
.expect("expected artifact path in task summary")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_runtime_uses_zeroclaw_provider_path_and_executes_browser_actions() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
@@ -386,7 +397,9 @@ fn compat_runtime_includes_default_workspace_skills_in_provider_request() {
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
|
||||
let default_skills_dir = workspace_root
|
||||
.join(".sgclaw-zeroclaw-workspace")
|
||||
.join("skills");
|
||||
write_skill_package(
|
||||
&default_skills_dir,
|
||||
"workspace-zhihu-skill",
|
||||
@@ -422,7 +435,9 @@ fn compat_runtime_includes_default_workspace_skills_in_provider_request() {
|
||||
|
||||
assert_eq!(summary, "已识别默认 workspace skill");
|
||||
assert_eq!(request_bodies.len(), 1);
|
||||
assert!(request_bodies[0].to_string().contains("workspace-zhihu-skill"));
|
||||
assert!(request_bodies[0]
|
||||
.to_string()
|
||||
.contains("workspace-zhihu-skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -439,7 +454,9 @@ fn handle_browser_message_loads_skills_from_configured_skills_dir() {
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
|
||||
let default_skills_dir = workspace_root
|
||||
.join(".sgclaw-zeroclaw-workspace")
|
||||
.join("skills");
|
||||
write_skill_package(
|
||||
&default_skills_dir,
|
||||
"workspace-only-skill",
|
||||
@@ -515,8 +532,7 @@ fn handle_browser_message_loads_skills_from_configured_skills_dir() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_routes_supported_instruction_to_compat_runtime_when_llm_is_configured(
|
||||
) {
|
||||
fn handle_browser_message_routes_supported_instruction_to_compat_runtime_when_llm_is_configured() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let first_response = json!({
|
||||
@@ -993,12 +1009,10 @@ 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!("已在百度搜索天气")
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1224,9 +1238,9 @@ fn compat_runtime_can_complete_a_text_only_turn_without_browser_tool_calls() {
|
||||
assert_eq!(summary, "这是纯文本回答");
|
||||
assert!(!flattened.contains("Browser tool contract"));
|
||||
assert!(!tool_names.contains(&"browser_action".to_string()));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(message, AgentMessage::Command { .. })
|
||||
}));
|
||||
assert!(!sent
|
||||
.iter()
|
||||
.any(|message| { matches!(message, AgentMessage::Command { .. }) }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1243,7 +1257,9 @@ fn compat_runtime_allows_read_skill_under_compact_mode_policy() {
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
|
||||
let default_skills_dir = workspace_root
|
||||
.join(".sgclaw-zeroclaw-workspace")
|
||||
.join("skills");
|
||||
write_skill_package(
|
||||
&default_skills_dir,
|
||||
"workspace-zhihu-skill",
|
||||
@@ -1307,7 +1323,9 @@ fn compat_runtime_exposes_browser_script_skill_tools_in_browser_attached_mode()
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
|
||||
let default_skills_dir = workspace_root
|
||||
.join(".sgclaw-zeroclaw-workspace")
|
||||
.join("skills");
|
||||
let skill_dir = write_skill_manifest_package(
|
||||
&default_skills_dir,
|
||||
"workspace-zhihu-skill",
|
||||
@@ -1404,7 +1422,9 @@ fn compat_runtime_executes_browser_script_skill_via_eval_without_gettext_probing
|
||||
start_fake_deepseek_server(vec![first_response, second_response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
|
||||
let default_skills_dir = workspace_root
|
||||
.join(".sgclaw-zeroclaw-workspace")
|
||||
.join("skills");
|
||||
let skill_dir = write_skill_manifest_package(
|
||||
&default_skills_dir,
|
||||
"workspace-zhihu-skill",
|
||||
@@ -1742,7 +1762,9 @@ fn compat_runtime_logs_read_skill_usage_with_skill_name() {
|
||||
start_fake_deepseek_server(vec![first_response, second_response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
|
||||
let default_skills_dir = workspace_root
|
||||
.join(".sgclaw-zeroclaw-workspace")
|
||||
.join("skills");
|
||||
write_skill_package(
|
||||
&default_skills_dir,
|
||||
"workspace-zhihu-skill",
|
||||
@@ -2018,14 +2040,17 @@ fn handle_browser_message_executes_real_zhihu_hotlist_skill_flow() {
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(1, json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "热榜项目 1", "1707万"], [2, "热榜项目 2", "1150万"]]
|
||||
}
|
||||
}))]));
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "热榜项目 1", "1707万"], [2, "热榜项目 2", "1150万"]]
|
||||
}
|
||||
}),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
@@ -2136,11 +2161,8 @@ fn handle_browser_message_chains_hotlist_skill_into_office_export_tool() {
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, _requests, server_handle) = start_fake_deepseek_server(vec![
|
||||
first_response,
|
||||
third_response,
|
||||
fourth_response,
|
||||
]);
|
||||
let (base_url, _requests, server_handle) =
|
||||
start_fake_deepseek_server(vec![first_response, third_response, fourth_response]);
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
@@ -2150,16 +2172,17 @@ fn handle_browser_message_chains_hotlist_skill_into_office_export_tool() {
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
success_browser_response(1, json!({
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]]
|
||||
}
|
||||
})),
|
||||
]));
|
||||
}),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
@@ -2225,84 +2248,10 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let output_path = workspace_root.join("out/zhihu-hotlist-screen.html");
|
||||
let output_path_str = output_path.to_string_lossy().to_string();
|
||||
let first_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "superrpa_browser",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "navigate",
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"url": "https://www.zhihu.com/hot"
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let second_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "superrpa_browser",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "getText",
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"selector": "main"
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let third_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_3",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "screen_html_export",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"rows": [
|
||||
[1, "问题一", "344万"],
|
||||
[2, "问题二", "266万"]
|
||||
],
|
||||
"output_path": output_path_str
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let fourth_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": format!("已生成知乎热榜大屏 {output_path_str}")
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, _requests, server_handle) = start_fake_deepseek_server(vec![
|
||||
first_response,
|
||||
second_response,
|
||||
third_response,
|
||||
fourth_response,
|
||||
]);
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
&base_url,
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(real_skill_lib_root().to_str().unwrap()),
|
||||
);
|
||||
@@ -2312,7 +2261,18 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() {
|
||||
success_browser_response(1, json!({ "navigated": true })),
|
||||
success_browser_response(
|
||||
2,
|
||||
json!({ "text": "知乎热榜\n1\n问题一\n344万热度\n2\n问题二\n266万热度" }),
|
||||
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }),
|
||||
),
|
||||
success_browser_response(
|
||||
3,
|
||||
json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]]
|
||||
}
|
||||
}),
|
||||
),
|
||||
]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
@@ -2335,22 +2295,39 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() {
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let summary = task_complete_summary(&sent);
|
||||
let generated = extract_generated_artifact_path(&summary, ".html");
|
||||
|
||||
assert!(summary.contains("已生成知乎热榜大屏"));
|
||||
assert!(summary.contains(".html"));
|
||||
assert!(generated.exists());
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary.contains("已生成知乎热榜大屏") && summary.contains(".html")
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "mode" && message == "zeroclaw_process_message_primary"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "mode" && message == "zeroclaw_process_message_primary"
|
||||
if level == "info" && message == "call zhihu-hotlist.extract_hotlist"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "call screen_html_export"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::Eval
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
@@ -2367,97 +2344,34 @@ fn handle_browser_message_runs_zhihu_hotlist_export_via_zeroclaw_primary_orchest
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let output_path = workspace_root.join("out/zhihu-hotlist-orchestrated.xlsx");
|
||||
let output_path_str = output_path.to_string_lossy().to_string();
|
||||
let first_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "superrpa_browser",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "navigate",
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"url": "https://www.zhihu.com/hot"
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let second_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "superrpa_browser",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "getText",
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"selector": "main"
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let third_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_3",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "openxml_office",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [
|
||||
[1, "问题一", "344万"],
|
||||
[2, "问题二", "266万"],
|
||||
[3, "问题三", "181万"]
|
||||
],
|
||||
"output_path": output_path_str
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let fourth_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": format!("已导出知乎热榜 Excel {output_path_str}")
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, _requests, server_handle) = start_fake_deepseek_server(vec![
|
||||
first_response,
|
||||
second_response,
|
||||
third_response,
|
||||
fourth_response,
|
||||
]);
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
&base_url,
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(real_skill_lib_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
success_browser_response(1, json!({ "navigated": true })),
|
||||
success_browser_response(
|
||||
1,
|
||||
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度\n3 问题三 181万热度" }),
|
||||
),
|
||||
success_browser_response(
|
||||
2,
|
||||
json!({ "text": "知乎热榜\n1\n问题一\n344万热度\n2\n问题二\n266万热度\n3\n问题三\n181万热度" }),
|
||||
json!({
|
||||
"text": {
|
||||
"source": "https://www.zhihu.com/hot",
|
||||
"sheet_name": "知乎热榜",
|
||||
"columns": ["rank", "title", "heat"],
|
||||
"rows": [
|
||||
[1, "问题一", "344万"],
|
||||
[2, "问题二", "266万"],
|
||||
[3, "问题三", "181万"]
|
||||
]
|
||||
}
|
||||
}),
|
||||
),
|
||||
]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
@@ -2480,19 +2394,13 @@ fn handle_browser_message_runs_zhihu_hotlist_export_via_zeroclaw_primary_orchest
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
let summary = sent
|
||||
.iter()
|
||||
.find_map(|message| match message {
|
||||
AgentMessage::TaskComplete { success, summary } if *success => Some(summary.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("expected successful task completion");
|
||||
let summary = task_complete_summary(&sent);
|
||||
let generated = extract_generated_artifact_path(&summary, ".xlsx");
|
||||
|
||||
assert!(summary.contains(".xlsx"));
|
||||
assert!(generated.exists());
|
||||
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
@@ -2621,20 +2529,34 @@ fn browser_submit_path_prefers_zeroclaw_process_message_orchestrator_for_zhihu_p
|
||||
|
||||
#[test]
|
||||
fn zhihu_publish_task_matches_primary_orchestration_gate() {
|
||||
assert!(sgclaw::compat::orchestration::should_use_primary_orchestration(
|
||||
"请直接发表这篇知乎文章,标题是测试标题,正文是第一段内容",
|
||||
Some("https://www.zhihu.com/"),
|
||||
Some("知乎"),
|
||||
));
|
||||
assert!(
|
||||
sgclaw::compat::orchestration::should_use_primary_orchestration(
|
||||
"请直接发表这篇知乎文章,标题是测试标题,正文是第一段内容",
|
||||
Some("https://www.zhihu.com/"),
|
||||
Some("知乎"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_article_entry_task_matches_primary_orchestration_gate() {
|
||||
assert!(sgclaw::compat::orchestration::should_use_primary_orchestration(
|
||||
"打开知乎发文章页面",
|
||||
Some("https://www.zhihu.com/"),
|
||||
Some("知乎"),
|
||||
assert!(
|
||||
sgclaw::compat::orchestration::should_use_primary_orchestration(
|
||||
"打开知乎发文章页面",
|
||||
Some("https://www.zhihu.com/"),
|
||||
Some("知乎"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_hotlist_export_routes_prefer_direct_execution() {
|
||||
use sgclaw::compat::workflow_executor::{prefers_direct_execution, WorkflowRoute};
|
||||
|
||||
assert!(prefers_direct_execution(
|
||||
&WorkflowRoute::ZhihuHotlistExportXlsx
|
||||
));
|
||||
assert!(prefers_direct_execution(&WorkflowRoute::ZhihuHotlistScreen));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2684,9 +2606,9 @@ fn zhihu_publish_without_article_inputs_returns_missing_fields_prompt() {
|
||||
summary.contains("正文")
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(message, AgentMessage::Command { .. })
|
||||
}));
|
||||
assert!(!sent
|
||||
.iter()
|
||||
.any(|message| { matches!(message, AgentMessage::Command { .. }) }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2748,7 +2670,8 @@ fn zhihu_publish_accepts_literal_backslash_n_between_title_and_body() {
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "标题:ai时代,普通人如何自救 \\n正文:第一段内容。 第二段内容。".to_string(),
|
||||
instruction: "标题:ai时代,普通人如何自救 \\n正文:第一段内容。 第二段内容。"
|
||||
.to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://www.zhihu.com/creator".to_string(),
|
||||
@@ -3055,9 +2978,9 @@ fn zhihu_publish_without_confirmation_returns_confirmation_before_any_browser_pr
|
||||
if *success && summary.contains("确认发布")
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(message, AgentMessage::Command { .. })
|
||||
}));
|
||||
assert!(!sent
|
||||
.iter()
|
||||
.any(|message| { matches!(message, AgentMessage::Command { .. }) }));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
@@ -3086,7 +3009,10 @@ fn zhihu_publish_after_confirmation_reports_login_block_without_selector_probing
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
success_browser_response(1, json!({ "navigated": true, "url": "https://www.zhihu.com/signin?next=%2Fcreator" })),
|
||||
success_browser_response(
|
||||
1,
|
||||
json!({ "navigated": true, "url": "https://www.zhihu.com/signin?next=%2Fcreator" }),
|
||||
),
|
||||
success_browser_response(
|
||||
2,
|
||||
json!({
|
||||
@@ -3200,11 +3126,8 @@ fn browser_orchestration_registers_superrpa_tools_natively() {
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![
|
||||
first_response,
|
||||
second_response,
|
||||
third_response,
|
||||
]);
|
||||
let (base_url, requests, server_handle) =
|
||||
start_fake_deepseek_server(vec![first_response, second_response, third_response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
@@ -3216,9 +3139,10 @@ fn browser_orchestration_registers_superrpa_tools_natively() {
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
success_browser_response(1, json!({ "text": "知乎热榜\n1\n问题一\n344万热度" })),
|
||||
]));
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
json!({ "text": "知乎热榜\n1\n问题一\n344万热度" }),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
@@ -3513,11 +3437,8 @@ fn handle_browser_message_executes_real_zhihu_navigate_skill_flow() {
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![
|
||||
first_response,
|
||||
second_response,
|
||||
third_response,
|
||||
]);
|
||||
let (base_url, requests, server_handle) =
|
||||
start_fake_deepseek_server(vec![first_response, second_response, third_response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let skills_dir = real_skill_lib_root();
|
||||
@@ -3709,9 +3630,14 @@ fn handle_browser_message_executes_real_zhihu_write_skill_flow() {
|
||||
params["url"].as_str() == Some("https://zhuanlan.zhihu.com/write")
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().filter(|message| {
|
||||
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval)
|
||||
}).count() >= 2);
|
||||
assert!(
|
||||
sent.iter()
|
||||
.filter(|message| {
|
||||
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval)
|
||||
})
|
||||
.count()
|
||||
>= 2
|
||||
);
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
|
||||
@@ -21,6 +21,7 @@ fn deepseek_settings_load_defaults_from_env() {
|
||||
assert_eq!(settings.api_key, "test-key");
|
||||
assert_eq!(settings.base_url, "https://api.deepseek.com");
|
||||
assert_eq!(settings.model, "deepseek-chat");
|
||||
assert_eq!(settings.skills_dir, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -29,6 +30,7 @@ fn deepseek_request_shape_matches_openai_compatible_chat_format() {
|
||||
api_key: "test-key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: None,
|
||||
});
|
||||
let messages = vec![
|
||||
ChatMessage {
|
||||
@@ -60,8 +62,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");
|
||||
}
|
||||
|
||||
@@ -109,7 +109,10 @@ fn plan_first_mode_builds_visible_preview_for_zhihu_excel_flow() {
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| step.contains("navigate https://www.zhihu.com/hot")));
|
||||
assert!(preview.steps.iter().any(|step| step.contains("getText main")));
|
||||
assert!(preview
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| step.contains("getText main")));
|
||||
assert!(preview
|
||||
.steps
|
||||
.iter()
|
||||
|
||||
@@ -30,13 +30,22 @@ async fn read_skill_inlines_referenced_markdown_files() {
|
||||
.unwrap();
|
||||
|
||||
let tool = ReadSkillTool::new(workspace_dir, false, None);
|
||||
let result = tool.execute(json!({ "name": "zhihu-hotlist" })).await.unwrap();
|
||||
let result = tool
|
||||
.execute(json!({ "name": "zhihu-hotlist" }))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("# Zhihu Hotlist"));
|
||||
assert!(result.output.contains("## Referenced File: references/collection-flow.md"));
|
||||
assert!(result.output.contains("Collect rows from the hotlist first."));
|
||||
assert!(result.output.contains("## Referenced File: references/data-quality.md"));
|
||||
assert!(result
|
||||
.output
|
||||
.contains("## Referenced File: references/collection-flow.md"));
|
||||
assert!(result
|
||||
.output
|
||||
.contains("Collect rows from the hotlist first."));
|
||||
assert!(result
|
||||
.output
|
||||
.contains("## Referenced File: references/data-quality.md"));
|
||||
assert!(result.output.contains("Mark partial metrics explicitly."));
|
||||
}
|
||||
|
||||
@@ -65,12 +74,21 @@ async fn read_skill_recursively_inlines_relative_asset_references() {
|
||||
.unwrap();
|
||||
|
||||
let tool = ReadSkillTool::new(workspace_dir, false, None);
|
||||
let result = tool.execute(json!({ "name": "zhihu-hotlist" })).await.unwrap();
|
||||
let result = tool
|
||||
.execute(json!({ "name": "zhihu-hotlist" }))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("## Referenced File: references/collection-flow.md"));
|
||||
assert!(result.output.contains("## Referenced File: assets/zhihu_hotlist_flow.source.json"));
|
||||
assert!(result.output.contains("\"selectors\": [\".HotList-list\", \".HotItem\"]"));
|
||||
assert!(result
|
||||
.output
|
||||
.contains("## Referenced File: references/collection-flow.md"));
|
||||
assert!(result
|
||||
.output
|
||||
.contains("## Referenced File: assets/zhihu_hotlist_flow.source.json"));
|
||||
assert!(result
|
||||
.output
|
||||
.contains("\"selectors\": [\".HotList-list\", \".HotItem\"]"));
|
||||
}
|
||||
|
||||
fn temp_workspace_dir() -> PathBuf {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use sgclaw::runtime::{RuntimeEngine, RuntimeProfile, ToolPolicy};
|
||||
use sgclaw::config::{BrowserBackend, OfficeBackend, PlannerMode, SgClawSettings};
|
||||
use sgclaw::runtime::{RuntimeEngine, RuntimeProfile, ToolPolicy};
|
||||
|
||||
#[test]
|
||||
fn browser_attached_profile_exposes_browser_surface_without_becoming_browser_only() {
|
||||
@@ -39,6 +39,23 @@ fn browser_attached_export_prompt_requires_openxml_completion() {
|
||||
assert!(instruction.contains("final answer must include the generated local .xlsx path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_attached_publish_prompt_requires_explicit_confirmation_before_clicking_publish() {
|
||||
let engine = RuntimeEngine::new(RuntimeProfile::BrowserAttached);
|
||||
|
||||
let instruction = engine.build_instruction(
|
||||
"请直接发表这篇知乎文章,标题是测试标题,正文是第一段内容",
|
||||
Some("https://www.zhihu.com/creator"),
|
||||
Some("知乎创作中心"),
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(instruction.contains("publish a Zhihu article"));
|
||||
assert!(instruction.contains("must not click publish without explicit human confirmation"));
|
||||
assert!(instruction.contains("ask for confirmation concisely"));
|
||||
assert!(instruction.contains("stop after the confirmation request"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_default_to_plan_first_superrpa_and_openxml_backends() {
|
||||
let settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||
|
||||
@@ -55,7 +55,7 @@ class SkillLibValidationTest(unittest.TestCase):
|
||||
self.assertIn("xlsx", record.tags)
|
||||
expected_location = (
|
||||
SKILLS_DIR / name / "SKILL.toml"
|
||||
if name == "zhihu-hotlist"
|
||||
if name in {"zhihu-hotlist", "zhihu-navigate", "zhihu-write"}
|
||||
else SKILLS_DIR / name / "SKILL.md"
|
||||
)
|
||||
self.assertEqual(record.location, expected_location)
|
||||
@@ -83,6 +83,17 @@ class SkillLibValidationTest(unittest.TestCase):
|
||||
self.assertTrue(
|
||||
(SKILLS_DIR / "zhihu-hotlist" / "scripts" / "extract_hotlist.js").is_file()
|
||||
)
|
||||
self.assertTrue((SKILLS_DIR / "zhihu-navigate" / "SKILL.toml").is_file())
|
||||
self.assertTrue(
|
||||
(SKILLS_DIR / "zhihu-navigate" / "scripts" / "open_creator_entry.js").is_file()
|
||||
)
|
||||
self.assertTrue((SKILLS_DIR / "zhihu-write" / "SKILL.toml").is_file())
|
||||
self.assertTrue(
|
||||
(SKILLS_DIR / "zhihu-write" / "scripts" / "prepare_article_editor.js").is_file()
|
||||
)
|
||||
self.assertTrue(
|
||||
(SKILLS_DIR / "zhihu-write" / "scripts" / "fill_article_draft.js").is_file()
|
||||
)
|
||||
|
||||
def test_each_skill_declares_superrpa_browser_contract(self):
|
||||
for name in [name for name in EXPECTED_SKILL_NAMES if name.startswith("zhihu-")]:
|
||||
|
||||
219
tests/skill_script_zhihu_write_test.py
Normal file
219
tests/skill_script_zhihu_write_test.py
Normal file
@@ -0,0 +1,219 @@
|
||||
import json
|
||||
import subprocess
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
PREPARE_SCRIPT_PATH = (
|
||||
REPO_ROOT.parent / "skill_lib" / "skills" / "zhihu-write" / "scripts" /
|
||||
"prepare_article_editor.js"
|
||||
)
|
||||
FILL_SCRIPT_PATH = (
|
||||
REPO_ROOT.parent / "skill_lib" / "skills" / "zhihu-write" / "scripts" /
|
||||
"fill_article_draft.js"
|
||||
)
|
||||
|
||||
|
||||
def run_browser_script(script_path: Path, *, args: dict, body_text: str, selectors: dict[str, list[dict]]) -> dict:
|
||||
node_script = textwrap.dedent(
|
||||
f"""
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const scriptPath = {json.dumps(str(script_path))};
|
||||
const args = {json.dumps(args, ensure_ascii=False)};
|
||||
const selectorMap = {json.dumps(selectors, ensure_ascii=False)};
|
||||
const bodyText = {json.dumps(body_text, ensure_ascii=False)};
|
||||
const source = fs.readFileSync(scriptPath, 'utf8');
|
||||
|
||||
function createNode(spec) {{
|
||||
const attrs = spec?.attrs || {{}};
|
||||
const node = {{
|
||||
tagName: String(spec?.tagName || 'DIV').toUpperCase(),
|
||||
textContent: String(spec?.textContent ?? ''),
|
||||
innerText: String(spec?.innerText ?? spec?.textContent ?? ''),
|
||||
innerHTML: String(spec?.innerHTML ?? spec?.textContent ?? ''),
|
||||
value: String(spec?.value ?? ''),
|
||||
children: [],
|
||||
focused: false,
|
||||
clicked: false,
|
||||
appendChild(child) {{
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}},
|
||||
focus() {{
|
||||
this.focused = true;
|
||||
}},
|
||||
click() {{
|
||||
this.clicked = true;
|
||||
}},
|
||||
dispatchEvent() {{
|
||||
return true;
|
||||
}},
|
||||
getAttribute(name) {{
|
||||
return Object.prototype.hasOwnProperty.call(attrs, name) ? attrs[name] : null;
|
||||
}},
|
||||
querySelector() {{
|
||||
return null;
|
||||
}},
|
||||
querySelectorAll() {{
|
||||
return [];
|
||||
}},
|
||||
getBoundingClientRect() {{
|
||||
return {{
|
||||
width: spec?.visible === false ? 0 : 100,
|
||||
height: spec?.visible === false ? 0 : 20,
|
||||
}};
|
||||
}},
|
||||
}};
|
||||
return node;
|
||||
}}
|
||||
|
||||
const created = new Map();
|
||||
|
||||
function createNodeList(selector) {{
|
||||
const specs = selectorMap[selector] || [];
|
||||
return specs.map((spec, index) => {{
|
||||
const key = `${{selector}}#${{index}}`;
|
||||
if (!created.has(key)) {{
|
||||
created.set(key, createNode(spec));
|
||||
}}
|
||||
return created.get(key);
|
||||
}});
|
||||
}}
|
||||
|
||||
const bodyNode = createNode({{ tagName: 'body', textContent: bodyText, innerText: bodyText }});
|
||||
const context = {{
|
||||
args,
|
||||
location: {{ href: 'https://zhuanlan.zhihu.com/write' }},
|
||||
document: {{
|
||||
body: bodyNode,
|
||||
createElement(tagName) {{
|
||||
return createNode({{ tagName }});
|
||||
}},
|
||||
createTextNode(text) {{
|
||||
return createNode({{ tagName: '#text', textContent: text, innerText: text }});
|
||||
}},
|
||||
querySelector(selector) {{
|
||||
if (selector === 'body') {{
|
||||
return bodyNode;
|
||||
}}
|
||||
return createNodeList(selector)[0] || null;
|
||||
}},
|
||||
querySelectorAll(selector) {{
|
||||
return createNodeList(selector);
|
||||
}},
|
||||
}},
|
||||
Event: class Event {{
|
||||
constructor(type, init = {{}}) {{
|
||||
this.type = type;
|
||||
this.bubbles = !!init.bubbles;
|
||||
this.composed = !!init.composed;
|
||||
}}
|
||||
}},
|
||||
console,
|
||||
JSON,
|
||||
Math,
|
||||
Number,
|
||||
Object,
|
||||
RegExp,
|
||||
Set,
|
||||
String,
|
||||
Array,
|
||||
Error,
|
||||
}};
|
||||
|
||||
try {{
|
||||
const result = vm.runInNewContext(`(function(){{\\n${{source}}\\n}})()`, context);
|
||||
process.stdout.write(JSON.stringify({{ ok: true, result, created: Object.fromEntries(created) }}));
|
||||
}} catch (error) {{
|
||||
process.stdout.write(JSON.stringify({{
|
||||
ok: false,
|
||||
error: String(error && error.message ? error.message : error),
|
||||
}}));
|
||||
process.exitCode = 1;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
completed = subprocess.run(
|
||||
["node", "--input-type=module", "-e", node_script],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
payload = json.loads(completed.stdout)
|
||||
if completed.returncode != 0:
|
||||
raise AssertionError(payload["error"])
|
||||
return payload
|
||||
|
||||
|
||||
class SkillScriptZhihuWriteTest(unittest.TestCase):
|
||||
def test_prepare_article_editor_accepts_role_textbox_title_and_generic_body_editor(self):
|
||||
payload = run_browser_script(
|
||||
PREPARE_SCRIPT_PATH,
|
||||
args={"desired_mode": "draft"},
|
||||
body_text="写文章 发布",
|
||||
selectors={
|
||||
"[role='textbox'][aria-label*='标题']": [
|
||||
{
|
||||
"tagName": "div",
|
||||
"attrs": {
|
||||
"role": "textbox",
|
||||
"aria-label": "标题",
|
||||
"contenteditable": "true",
|
||||
},
|
||||
}
|
||||
],
|
||||
"div[contenteditable='true']": [
|
||||
{
|
||||
"tagName": "div",
|
||||
"attrs": {
|
||||
"contenteditable": "true",
|
||||
"data-placeholder": "在这里输入正文",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(payload["result"]["status"], "editor_ready")
|
||||
|
||||
def test_fill_article_draft_accepts_role_textbox_title_and_generic_body_editor(self):
|
||||
payload = run_browser_script(
|
||||
FILL_SCRIPT_PATH,
|
||||
args={
|
||||
"title": "测试标题",
|
||||
"body": "第一段\n第二段",
|
||||
"publish_mode": "false",
|
||||
},
|
||||
body_text="写文章 发布",
|
||||
selectors={
|
||||
"[role='textbox'][aria-label*='标题']": [
|
||||
{
|
||||
"tagName": "div",
|
||||
"attrs": {
|
||||
"role": "textbox",
|
||||
"aria-label": "标题",
|
||||
"contenteditable": "true",
|
||||
},
|
||||
}
|
||||
],
|
||||
"div[contenteditable='true']": [
|
||||
{
|
||||
"tagName": "div",
|
||||
"attrs": {
|
||||
"contenteditable": "true",
|
||||
"data-placeholder": "在这里输入正文",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(payload["result"]["status"], "draft_ready")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user