feat: add websocket browser service runtime
Wire the service/browser runtime onto the websocket-driven execution path and add the new browser/service modules needed for the submit flow and runtime integration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Url;
|
||||
@@ -8,22 +9,23 @@ use serde_json::{json, Value};
|
||||
use zeroclaw::skills::{Skill, SkillTool};
|
||||
use zeroclaw::tools::{Tool, ToolResult};
|
||||
|
||||
use crate::pipe::{Action, BrowserPipeTool, Transport};
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::pipe::Action;
|
||||
|
||||
pub struct BrowserScriptSkillTool<T: Transport> {
|
||||
pub struct BrowserScriptSkillTool {
|
||||
tool_name: String,
|
||||
tool_description: String,
|
||||
script_path: PathBuf,
|
||||
args: HashMap<String, String>,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
}
|
||||
|
||||
impl<T: Transport> BrowserScriptSkillTool<T> {
|
||||
impl BrowserScriptSkillTool {
|
||||
pub fn new(
|
||||
skill_name: &str,
|
||||
tool: &SkillTool,
|
||||
skill_root: &Path,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let script_path = skill_root.join(&tool.command);
|
||||
let canonical_skill_root = skill_root
|
||||
@@ -83,7 +85,7 @@ impl<T: Transport> BrowserScriptSkillTool<T> {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Transport + 'static> Tool for BrowserScriptSkillTool<T> {
|
||||
impl Tool for BrowserScriptSkillTool {
|
||||
fn name(&self) -> &str {
|
||||
&self.tool_name
|
||||
}
|
||||
@@ -175,12 +177,16 @@ impl<T: Transport + 'static> Tool for BrowserScriptSkillTool<T> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_browser_script_skill_tools<T: Transport + 'static>(
|
||||
pub fn build_browser_script_skill_tools(
|
||||
skills: &[Skill],
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
) -> Result<Vec<Box<dyn Tool>>, anyhow::Error> {
|
||||
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
|
||||
if !browser_tool.supports_eval() {
|
||||
return Ok(tools);
|
||||
}
|
||||
|
||||
for skill in skills {
|
||||
let Some(location) = skill.location.as_ref() else {
|
||||
continue;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Url;
|
||||
use serde_json::{json, Map, Value};
|
||||
use zeroclaw::tools::{Tool, ToolResult};
|
||||
|
||||
use crate::pipe::{Action, BrowserPipeTool, ExecutionSurfaceMetadata, Transport};
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::pipe::{Action, ExecutionSurfaceMetadata};
|
||||
|
||||
pub const BROWSER_ACTION_TOOL_NAME: &str = "browser_action";
|
||||
pub const SUPERRPA_BROWSER_TOOL_NAME: &str = "superrpa_browser";
|
||||
@@ -17,14 +20,14 @@ const MAX_DATA_ARRAY_ITEMS: usize = 12;
|
||||
const MAX_DATA_OBJECT_FIELDS: usize = 24;
|
||||
const MAX_DATA_RECURSION_DEPTH: usize = 4;
|
||||
|
||||
pub struct ZeroClawBrowserTool<T: Transport> {
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
pub struct ZeroClawBrowserTool {
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
tool_name: &'static str,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
impl<T: Transport> ZeroClawBrowserTool<T> {
|
||||
pub fn new(browser_tool: BrowserPipeTool<T>) -> Self {
|
||||
impl ZeroClawBrowserTool {
|
||||
pub fn new(browser_tool: Arc<dyn BrowserBackend>) -> Self {
|
||||
Self::named(
|
||||
browser_tool,
|
||||
BROWSER_ACTION_TOOL_NAME,
|
||||
@@ -32,7 +35,7 @@ impl<T: Transport> ZeroClawBrowserTool<T> {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_superrpa(browser_tool: BrowserPipeTool<T>) -> Self {
|
||||
pub fn new_superrpa(browser_tool: Arc<dyn BrowserBackend>) -> Self {
|
||||
Self::named(
|
||||
browser_tool,
|
||||
SUPERRPA_BROWSER_TOOL_NAME,
|
||||
@@ -41,7 +44,7 @@ impl<T: Transport> ZeroClawBrowserTool<T> {
|
||||
}
|
||||
|
||||
fn named(
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
browser_tool: Arc<dyn BrowserBackend>,
|
||||
tool_name: &'static str,
|
||||
description: &'static str,
|
||||
) -> Self {
|
||||
@@ -58,7 +61,7 @@ impl<T: Transport> ZeroClawBrowserTool<T> {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
|
||||
impl Tool for ZeroClawBrowserTool {
|
||||
fn name(&self) -> &str {
|
||||
self.tool_name
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use zeroclaw::tools::{Tool, ToolResult};
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::{CompressionMethod, ZipWriter};
|
||||
|
||||
const OPENXML_OFFICE_TOOL_NAME: &str = "openxml_office";
|
||||
const DEFAULT_SHEET_NAME: &str = "知乎热榜";
|
||||
@@ -128,7 +131,9 @@ impl Tool for OpenXmlOfficeTool {
|
||||
write_payload_json(&payload_path, &normalized_rows)?;
|
||||
write_request_json(&request_path, &template_path, &payload_path, &output_path)?;
|
||||
|
||||
let rendered = run_openxml_cli(&request_path)?;
|
||||
let rendered = run_openxml_cli(&request_path).or_else(|_| {
|
||||
render_locally(&template_path, &payload_path, &output_path)
|
||||
})?;
|
||||
let artifact_path = rendered["data"]["artifact"]["path"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
@@ -280,9 +285,14 @@ fn run_openxml_cli(request_path: &Path) -> anyhow::Result<Value> {
|
||||
.parent()
|
||||
.map(|path| path.join("openxml_cli").join("Cargo.toml"))
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to resolve openxml_cli manifest path"))?;
|
||||
let binary_name = if cfg!(windows) {
|
||||
"openxml-cli.exe"
|
||||
} else {
|
||||
"openxml-cli"
|
||||
};
|
||||
let binary_path = manifest_path
|
||||
.parent()
|
||||
.map(|path| path.join("target").join("debug").join("openxml-cli"))
|
||||
.map(|path| path.join("target").join("debug").join(binary_name))
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to resolve openxml_cli binary path"))?;
|
||||
|
||||
let output = if binary_path.exists() {
|
||||
@@ -325,6 +335,87 @@ fn run_openxml_cli(request_path: &Path) -> anyhow::Result<Value> {
|
||||
Ok(serde_json::from_str(&stdout)?)
|
||||
}
|
||||
|
||||
fn render_locally(template_path: &Path, payload_path: &Path, output_path: &Path) -> anyhow::Result<Value> {
|
||||
let payload: Value = serde_json::from_slice(&fs::read(payload_path)?)?;
|
||||
let variables = payload["variables"]
|
||||
.as_object()
|
||||
.ok_or_else(|| anyhow::anyhow!("payload.variables must be an object"))?;
|
||||
|
||||
let worksheet = render_template_xml(&worksheet_xml_from_xlsx(template_path)?, variables);
|
||||
write_rendered_xlsx(template_path, output_path, "xl/worksheets/sheet1.xml", &worksheet)?;
|
||||
|
||||
Ok(json!({
|
||||
"data": {
|
||||
"artifact": {
|
||||
"path": output_path.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn worksheet_xml_from_xlsx(path: &Path) -> anyhow::Result<String> {
|
||||
let file = fs::File::open(path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)?;
|
||||
let mut sheet = archive.by_name("xl/worksheets/sheet1.xml")?;
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut sheet, &mut xml)?;
|
||||
Ok(xml)
|
||||
}
|
||||
|
||||
fn render_template_xml(
|
||||
template: &str,
|
||||
variables: &serde_json::Map<String, Value>,
|
||||
) -> String {
|
||||
let mut rendered = template.to_string();
|
||||
for (key, value) in variables {
|
||||
let placeholder = format!("{{{{{key}}}}}");
|
||||
let replacement = value.as_str().unwrap_or_default();
|
||||
rendered = rendered.replace(&placeholder, &xml_escape(replacement));
|
||||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
fn write_rendered_xlsx(
|
||||
template_path: &Path,
|
||||
output_path: &Path,
|
||||
replaced_entry: &str,
|
||||
replaced_body: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
if let Some(parent) = output_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
if output_path.exists() {
|
||||
fs::remove_file(output_path)?;
|
||||
}
|
||||
|
||||
let input = fs::File::open(template_path)?;
|
||||
let mut archive = zip::ZipArchive::new(input)?;
|
||||
let output = fs::File::create(output_path)?;
|
||||
let mut writer = ZipWriter::new(output);
|
||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
|
||||
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive.by_index(index)?;
|
||||
let name = entry.name().to_string();
|
||||
writer.start_file(name.as_str(), options)?;
|
||||
if name == replaced_entry {
|
||||
writer.write_all(replaced_body.as_bytes())?;
|
||||
} else {
|
||||
std::io::copy(&mut entry, &mut writer)?;
|
||||
}
|
||||
}
|
||||
|
||||
writer.finish()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn xml_escape(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn value_to_string(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(text) => text.clone(),
|
||||
@@ -336,46 +427,58 @@ fn value_to_string(value: &Value) -> String {
|
||||
}
|
||||
|
||||
fn write_hotlist_template(path: &Path, row_count: usize) -> anyhow::Result<()> {
|
||||
let build_root = path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("template path has no parent"))?
|
||||
.join("template-build");
|
||||
fs::create_dir_all(build_root.join("_rels"))?;
|
||||
fs::create_dir_all(build_root.join("docProps"))?;
|
||||
fs::create_dir_all(build_root.join("xl/_rels"))?;
|
||||
fs::create_dir_all(build_root.join("xl/worksheets"))?;
|
||||
write_zip_file(&path, &[Content {
|
||||
path: "[Content_Types].xml",
|
||||
body: content_types_xml().to_string(),
|
||||
},
|
||||
Content {
|
||||
path: "_rels/.rels",
|
||||
body: root_rels_xml().to_string(),
|
||||
},
|
||||
Content {
|
||||
path: "docProps/app.xml",
|
||||
body: app_xml().to_string(),
|
||||
},
|
||||
Content {
|
||||
path: "docProps/core.xml",
|
||||
body: core_xml().to_string(),
|
||||
},
|
||||
Content {
|
||||
path: "xl/workbook.xml",
|
||||
body: workbook_xml().to_string(),
|
||||
},
|
||||
Content {
|
||||
path: "xl/_rels/workbook.xml.rels",
|
||||
body: workbook_rels_xml().to_string(),
|
||||
},
|
||||
Content {
|
||||
path: "xl/worksheets/sheet1.xml",
|
||||
body: worksheet_xml(row_count),
|
||||
}])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fs::write(build_root.join("[Content_Types].xml"), content_types_xml())?;
|
||||
fs::write(build_root.join("_rels/.rels"), root_rels_xml())?;
|
||||
fs::write(build_root.join("docProps/app.xml"), app_xml())?;
|
||||
fs::write(build_root.join("docProps/core.xml"), core_xml())?;
|
||||
fs::write(build_root.join("xl/workbook.xml"), workbook_xml())?;
|
||||
fs::write(
|
||||
build_root.join("xl/_rels/workbook.xml.rels"),
|
||||
workbook_rels_xml(),
|
||||
)?;
|
||||
fs::write(
|
||||
build_root.join("xl/worksheets/sheet1.xml"),
|
||||
worksheet_xml(row_count),
|
||||
)?;
|
||||
struct Content<'a> {
|
||||
path: &'a str,
|
||||
body: String,
|
||||
}
|
||||
|
||||
fn write_zip_file(path: &Path, entries: &[Content<'_>]) -> anyhow::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
|
||||
let zip = Command::new("zip")
|
||||
.current_dir(&build_root)
|
||||
.args(["-q", "-r", path.to_string_lossy().as_ref(), "."])
|
||||
.output()?;
|
||||
if !zip.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&zip.stderr);
|
||||
return Err(anyhow::anyhow!(format!(
|
||||
"failed to create xlsx template: {}",
|
||||
stderr.trim()
|
||||
)));
|
||||
let file = fs::File::create(path)?;
|
||||
let mut zip = ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
|
||||
for entry in entries {
|
||||
zip.start_file(entry.path, options)?;
|
||||
zip.write_all(entry.body.as_bytes())?;
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&build_root);
|
||||
zip.finish()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::browser::BrowserBackend;
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::config::SgClawSettings;
|
||||
use crate::pipe::{BrowserPipeTool, PipeError, Transport};
|
||||
@@ -26,6 +28,68 @@ pub fn should_use_primary_orchestration(
|
||||
crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) && needs_export
|
||||
}
|
||||
|
||||
pub fn execute_task_with_browser_backend(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> Result<String, PipeError> {
|
||||
let route = crate::compat::workflow_executor::detect_route(
|
||||
instruction,
|
||||
task_context.page_url.as_deref(),
|
||||
task_context.page_title.as_deref(),
|
||||
);
|
||||
if let Some(route) = route.clone() {
|
||||
if crate::compat::workflow_executor::prefers_direct_execution(&route) {
|
||||
return crate::compat::workflow_executor::execute_route_with_browser_backend(
|
||||
transport,
|
||||
browser_backend.clone(),
|
||||
workspace_root,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
);
|
||||
}
|
||||
}
|
||||
let primary_result = crate::compat::runtime::execute_task_with_browser_backend(
|
||||
transport,
|
||||
browser_backend.clone(),
|
||||
instruction,
|
||||
task_context,
|
||||
workspace_root,
|
||||
settings,
|
||||
);
|
||||
|
||||
match (route, primary_result) {
|
||||
(Some(route), Ok(summary))
|
||||
if crate::compat::workflow_executor::should_fallback_after_summary(
|
||||
&summary, &route,
|
||||
) =>
|
||||
{
|
||||
crate::compat::workflow_executor::execute_route_with_browser_backend(
|
||||
transport,
|
||||
browser_backend,
|
||||
workspace_root,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
)
|
||||
}
|
||||
(_, Ok(summary)) => Ok(summary),
|
||||
(Some(route), Err(_)) => crate::compat::workflow_executor::execute_route_with_browser_backend(
|
||||
transport,
|
||||
browser_backend,
|
||||
workspace_root,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
),
|
||||
(None, Err(err)) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_util::{stream, StreamExt};
|
||||
@@ -8,6 +9,7 @@ use zeroclaw::config::Config as ZeroClawConfig;
|
||||
use zeroclaw::providers::traits::{ProviderCapabilities, StreamEvent, StreamOptions, StreamResult};
|
||||
use zeroclaw::providers::{self, ChatMessage, ChatRequest, ChatResponse, Provider};
|
||||
|
||||
use crate::browser::{BrowserBackend, PipeBrowserBackend};
|
||||
use crate::compat::browser_script_skill_tool::build_browser_script_skill_tools;
|
||||
use crate::compat::browser_tool_adapter::ZeroClawBrowserTool;
|
||||
use crate::compat::config_adapter::{
|
||||
@@ -47,6 +49,32 @@ pub fn execute_task<T: Transport + 'static>(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn execute_task_with_browser_backend(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
workspace_root: &Path,
|
||||
settings: &SgClawSettings,
|
||||
) -> Result<String, PipeError> {
|
||||
let config = build_zeroclaw_config_from_sgclaw_settings(workspace_root, settings);
|
||||
let skills_dir = resolve_skills_dir_from_sgclaw_settings(workspace_root, settings);
|
||||
let provider = build_provider(&config)?;
|
||||
let runtime = tokio::runtime::Runtime::new()
|
||||
.map_err(|err| PipeError::Protocol(format!("failed to create tokio runtime: {err}")))?;
|
||||
|
||||
runtime.block_on(execute_task_with_provider(
|
||||
transport,
|
||||
browser_backend,
|
||||
provider,
|
||||
instruction,
|
||||
task_context,
|
||||
config,
|
||||
skills_dir,
|
||||
settings.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
@@ -63,7 +91,7 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
|
||||
runtime.block_on(execute_task_with_provider(
|
||||
transport,
|
||||
browser_tool,
|
||||
Arc::new(PipeBrowserBackend::from_inner(browser_tool)),
|
||||
provider,
|
||||
instruction,
|
||||
task_context,
|
||||
@@ -73,9 +101,9 @@ pub fn execute_task_with_sgclaw_settings<T: Transport + 'static>(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn execute_task_with_provider<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
pub async fn execute_task_with_provider(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
provider: Box<dyn Provider>,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
@@ -116,11 +144,13 @@ pub async fn execute_task_with_provider<T: Transport + 'static>(
|
||||
message: format!("loaded skills: {}", loaded_skill_labels.join(", ")),
|
||||
})?;
|
||||
}
|
||||
let browser_tool_for_scripts = browser_tool.clone();
|
||||
let browser_tool_for_scripts = browser_backend.clone();
|
||||
let browser_tool_for_superrpa = browser_backend.clone();
|
||||
let browser_tool_for_browser_action = browser_backend;
|
||||
let mut tools: Vec<Box<dyn zeroclaw::tools::Tool>> = if browser_surface_present {
|
||||
vec![
|
||||
Box::new(ZeroClawBrowserTool::new_superrpa(browser_tool.clone())),
|
||||
Box::new(ZeroClawBrowserTool::new(browser_tool)),
|
||||
Box::new(ZeroClawBrowserTool::new_superrpa(browser_tool_for_superrpa)),
|
||||
Box::new(ZeroClawBrowserTool::new(browser_tool_for_browser_action)),
|
||||
]
|
||||
} else {
|
||||
Vec::new()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -7,6 +8,7 @@ use regex::Regex;
|
||||
use serde_json::{json, Value};
|
||||
use zeroclaw::tools::Tool;
|
||||
|
||||
use crate::browser::{BrowserBackend, PipeBrowserBackend};
|
||||
use crate::compat::openxml_office_tool::OpenXmlOfficeTool;
|
||||
use crate::compat::runtime::CompatTaskContext;
|
||||
use crate::compat::screen_html_export_tool::ScreenHtmlExportTool;
|
||||
@@ -21,8 +23,13 @@ const ZHIHU_CREATOR_URL: &str = "https://www.zhihu.com/creator";
|
||||
const ZHIHU_EDITOR_URL: &str = "https://zhuanlan.zhihu.com/write";
|
||||
const HOTLIST_READY_POLL_ATTEMPTS: usize = 10;
|
||||
const HOTLIST_READY_POLL_INTERVAL: Duration = Duration::from_millis(500);
|
||||
// Simplified readiness pattern: only checks that *some* heat metric exists
|
||||
// (e.g. "3440万热度", "2.1亿"). The full rank-title-heat structure is validated
|
||||
// later by the extraction script. Using a simple pattern avoids problems with
|
||||
// the multi-line innerText format where rank, title, and heat are on separate
|
||||
// lines (`.` does not cross newlines by default).
|
||||
const HOTLIST_TEXT_READY_PATTERN: &str =
|
||||
r"(?:^|\n)\s*1(?:[.、]|\s)+.+\d+(?:\.\d+)?\s*(?:万|亿|k|K|m|M)(?:热度)?";
|
||||
r"\d+(?:\.\d+)?\s*(?:万|亿|k|K|m|M)\s*(?:热度)?";
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkflowRoute {
|
||||
ZhihuHotlistExportXlsx,
|
||||
@@ -113,9 +120,9 @@ pub fn should_fallback_after_summary(summary: &str, route: &WorkflowRoute) -> bo
|
||||
)
|
||||
}
|
||||
|
||||
pub fn execute_route<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
pub fn execute_route_with_browser_backend(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_backend: Arc<dyn BrowserBackend>,
|
||||
workspace_root: &Path,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
@@ -124,37 +131,61 @@ pub fn execute_route<T: Transport + 'static>(
|
||||
match route {
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen => {
|
||||
let top_n = extract_top_n(instruction);
|
||||
let items = collect_hotlist_items(transport, browser_tool, top_n, task_context)?;
|
||||
let items = collect_hotlist_items(transport, browser_backend.as_ref(), top_n, task_context)?;
|
||||
if items.is_empty() {
|
||||
return Err(PipeError::Protocol(
|
||||
"知乎热榜采集失败:未能从页面文本中解析到热榜条目".to_string(),
|
||||
));
|
||||
}
|
||||
match route {
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx => {
|
||||
export_xlsx(transport, workspace_root, &items)
|
||||
}
|
||||
WorkflowRoute::ZhihuHotlistScreen => {
|
||||
export_screen(transport, workspace_root, &items)
|
||||
}
|
||||
WorkflowRoute::ZhihuHotlistExportXlsx => export_xlsx(transport, workspace_root, &items),
|
||||
WorkflowRoute::ZhihuHotlistScreen => export_screen(transport, workspace_root, &items),
|
||||
_ => unreachable!("handled by outer match"),
|
||||
}
|
||||
}
|
||||
WorkflowRoute::ZhihuArticleEntry => {
|
||||
execute_zhihu_article_entry_route(transport, browser_tool)
|
||||
}
|
||||
WorkflowRoute::ZhihuArticleDraft => {
|
||||
execute_zhihu_article_route(transport, browser_tool, instruction, task_context, false)
|
||||
}
|
||||
WorkflowRoute::ZhihuArticlePublish => {
|
||||
execute_zhihu_article_route(transport, browser_tool, instruction, task_context, true)
|
||||
execute_zhihu_article_entry_route(transport, browser_backend.as_ref())
|
||||
}
|
||||
WorkflowRoute::ZhihuArticleDraft => execute_zhihu_article_route(
|
||||
transport,
|
||||
browser_backend.as_ref(),
|
||||
instruction,
|
||||
task_context,
|
||||
false,
|
||||
),
|
||||
WorkflowRoute::ZhihuArticlePublish => execute_zhihu_article_route(
|
||||
transport,
|
||||
browser_backend.as_ref(),
|
||||
instruction,
|
||||
task_context,
|
||||
true,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_hotlist_items<T: Transport + 'static>(
|
||||
pub fn execute_route<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
workspace_root: &Path,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
route: WorkflowRoute,
|
||||
) -> Result<String, PipeError> {
|
||||
let browser_backend: Arc<dyn BrowserBackend> =
|
||||
Arc::new(PipeBrowserBackend::from_inner(browser_tool.clone()));
|
||||
execute_route_with_browser_backend(
|
||||
transport,
|
||||
browser_backend,
|
||||
workspace_root,
|
||||
instruction,
|
||||
task_context,
|
||||
route,
|
||||
)
|
||||
}
|
||||
|
||||
fn collect_hotlist_items(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
top_n: usize,
|
||||
task_context: &CompatTaskContext,
|
||||
) -> Result<Vec<HotlistItem>, PipeError> {
|
||||
@@ -185,9 +216,9 @@ fn collect_hotlist_items<T: Transport + 'static>(
|
||||
parse_hotlist_items_payload(response.data.get("text").unwrap_or(&response.data))
|
||||
}
|
||||
|
||||
fn ensure_hotlist_page_ready<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
fn ensure_hotlist_page_ready(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
top_n: usize,
|
||||
task_context: &CompatTaskContext,
|
||||
) -> Result<Option<Vec<HotlistItem>>, PipeError> {
|
||||
@@ -227,9 +258,9 @@ fn ensure_hotlist_page_ready<T: Transport + 'static>(
|
||||
Err(last_error.unwrap_or_else(|| PipeError::Protocol("知乎热榜页面未就绪".to_string())))
|
||||
}
|
||||
|
||||
fn probe_hotlist_extractor<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
fn probe_hotlist_extractor(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
top_n: usize,
|
||||
) -> Result<Option<Vec<HotlistItem>>, PipeError> {
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
@@ -242,19 +273,32 @@ fn probe_hotlist_extractor<T: Transport + 'static>(
|
||||
ZHIHU_DOMAIN,
|
||||
)?;
|
||||
if !response.success {
|
||||
eprintln!("probe_hotlist_extractor: eval not successful data={}", response.data);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match parse_hotlist_items_payload(response.data.get("text").unwrap_or(&response.data)) {
|
||||
let eval_text = response.data.get("text").unwrap_or(&response.data);
|
||||
let eval_preview: String = eval_text
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.take(300)
|
||||
.collect();
|
||||
eprintln!(
|
||||
"probe_hotlist_extractor: eval_len={} preview={eval_preview:?}",
|
||||
eval_text.as_str().unwrap_or_default().len()
|
||||
);
|
||||
|
||||
match parse_hotlist_items_payload(eval_text) {
|
||||
Ok(items) if !items.is_empty() => Ok(Some(items)),
|
||||
Ok(_) => Ok(None),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn navigate_hotlist_page<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
fn navigate_hotlist_page(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
) -> Result<(), PipeError> {
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
level: "info".to_string(),
|
||||
@@ -275,16 +319,34 @@ fn navigate_hotlist_page<T: Transport + 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_for_hotlist_readiness<T: Transport + 'static>(
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
) -> Result<bool, PipeError> {
|
||||
fn poll_for_hotlist_readiness(browser_tool: &dyn BrowserBackend) -> Result<bool, PipeError> {
|
||||
let ready_pattern =
|
||||
Regex::new(HOTLIST_TEXT_READY_PATTERN).expect("hotlist readiness regex must compile");
|
||||
for attempt in 0..HOTLIST_READY_POLL_ATTEMPTS {
|
||||
let response =
|
||||
browser_tool.invoke(Action::GetText, json!({ "selector": "body" }), ZHIHU_DOMAIN)?;
|
||||
// Tolerate individual GetText failures (e.g. callback timeout) – they
|
||||
// are expected while the page is still loading or the callback delivery
|
||||
// path is not yet established. Only a PipeClosed error is fatal.
|
||||
let response = match browser_tool.invoke(
|
||||
Action::GetText,
|
||||
json!({ "selector": "body" }),
|
||||
ZHIHU_DOMAIN,
|
||||
) {
|
||||
Ok(resp) => resp,
|
||||
Err(PipeError::PipeClosed) => return Err(PipeError::PipeClosed),
|
||||
Err(_) => {
|
||||
if attempt + 1 < HOTLIST_READY_POLL_ATTEMPTS {
|
||||
thread::sleep(HOTLIST_READY_POLL_INTERVAL);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if response.success {
|
||||
let payload = response.data.get("text").unwrap_or(&response.data);
|
||||
let preview: String = payload.as_str().unwrap_or_default().chars().take(200).collect();
|
||||
eprintln!(
|
||||
"poll_hotlist_readiness[{attempt}]: text_len={} preview={preview:?}",
|
||||
payload.as_str().unwrap_or_default().len()
|
||||
);
|
||||
if hotlist_text_looks_ready(payload, &ready_pattern) {
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -302,8 +364,8 @@ fn hotlist_text_looks_ready(payload: &Value, ready_pattern: &Regex) -> bool {
|
||||
text.contains("热榜") && ready_pattern.is_match(text)
|
||||
}
|
||||
|
||||
fn export_xlsx<T: Transport>(
|
||||
transport: &T,
|
||||
fn export_xlsx(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
workspace_root: &Path,
|
||||
items: &[HotlistItem],
|
||||
) -> Result<String, PipeError> {
|
||||
@@ -341,8 +403,8 @@ fn export_xlsx<T: Transport>(
|
||||
Ok(format!("已导出知乎热榜 Excel {output_path}"))
|
||||
}
|
||||
|
||||
fn export_screen<T: Transport>(
|
||||
transport: &T,
|
||||
fn export_screen(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
workspace_root: &Path,
|
||||
items: &[HotlistItem],
|
||||
) -> Result<String, PipeError> {
|
||||
@@ -376,9 +438,9 @@ fn export_screen<T: Transport>(
|
||||
Ok(format!("已生成知乎热榜大屏 {output_path}"))
|
||||
}
|
||||
|
||||
fn execute_zhihu_article_route<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
fn execute_zhihu_article_route(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
instruction: &str,
|
||||
task_context: &CompatTaskContext,
|
||||
publish_mode: bool,
|
||||
@@ -479,9 +541,9 @@ fn execute_zhihu_article_route<T: Transport + 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_zhihu_article_entry_route<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
fn execute_zhihu_article_entry_route(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
) -> Result<String, PipeError> {
|
||||
navigate_zhihu_page(transport, browser_tool, ZHIHU_CREATOR_URL)?;
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
@@ -596,9 +658,9 @@ fn extract_top_n(instruction: &str) -> usize {
|
||||
.unwrap_or(10)
|
||||
}
|
||||
|
||||
fn navigate_zhihu_page<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
fn navigate_zhihu_page(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
url: &str,
|
||||
) -> Result<(), PipeError> {
|
||||
transport.send(&AgentMessage::LogEntry {
|
||||
@@ -616,8 +678,8 @@ fn navigate_zhihu_page<T: Transport + 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_browser_skill_script<T: Transport + 'static>(
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
fn execute_browser_skill_script(
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
skill_name: &str,
|
||||
script_name: &str,
|
||||
args: Value,
|
||||
@@ -641,9 +703,9 @@ fn execute_browser_skill_script<T: Transport + 'static>(
|
||||
))
|
||||
}
|
||||
|
||||
fn navigate_to_editor_after_creator_entry<T: Transport + 'static>(
|
||||
transport: &T,
|
||||
browser_tool: &BrowserPipeTool<T>,
|
||||
fn navigate_to_editor_after_creator_entry(
|
||||
transport: &dyn crate::agent::AgentEventSink,
|
||||
browser_tool: &dyn BrowserBackend,
|
||||
creator_state: &Value,
|
||||
) -> Result<(), PipeError> {
|
||||
let status = payload_status(creator_state);
|
||||
@@ -679,7 +741,7 @@ mod tests {
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::pipe::{BrowserMessage, Timing};
|
||||
use crate::pipe::{BrowserMessage, CommandOutput, ExecutionSurfaceMetadata, Timing};
|
||||
use crate::security::MacPolicy;
|
||||
|
||||
struct MockWorkflowTransport {
|
||||
@@ -743,6 +805,245 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeBrowserBackend {
|
||||
responses: Mutex<VecDeque<Result<CommandOutput, PipeError>>>,
|
||||
invocations: Mutex<Vec<(Action, Value, String)>>,
|
||||
}
|
||||
|
||||
impl FakeBrowserBackend {
|
||||
fn new(responses: Vec<Result<CommandOutput, PipeError>>) -> Self {
|
||||
Self {
|
||||
responses: Mutex::new(VecDeque::from(responses)),
|
||||
invocations: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn invocations(&self) -> Vec<(Action, Value, String)> {
|
||||
self.invocations.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserBackend for FakeBrowserBackend {
|
||||
fn invoke(
|
||||
&self,
|
||||
action: Action,
|
||||
params: Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<CommandOutput, PipeError> {
|
||||
self.invocations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((action, params, expected_domain.to_string()));
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| Err(PipeError::Timeout))
|
||||
}
|
||||
|
||||
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
|
||||
ExecutionSurfaceMetadata::privileged_browser_pipe("fake_backend")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_route_with_browser_backend_runs_direct_route_with_ws_style_backend() {
|
||||
let transport = Arc::new(MockWorkflowTransport::new(vec![]));
|
||||
let backend = Arc::new(FakeBrowserBackend::new(vec![
|
||||
Ok(CommandOutput {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 1,
|
||||
},
|
||||
}),
|
||||
Ok(CommandOutput {
|
||||
seq: 2,
|
||||
success: true,
|
||||
data: json!({"text": "已进入编辑器"}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 1,
|
||||
},
|
||||
}),
|
||||
Ok(CommandOutput {
|
||||
seq: 3,
|
||||
success: true,
|
||||
data: json!({
|
||||
"text": {
|
||||
"status": "editor_ready",
|
||||
"current_url": "https://zhuanlan.zhihu.com/write"
|
||||
}
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 1,
|
||||
},
|
||||
}),
|
||||
]));
|
||||
|
||||
let summary = execute_route_with_browser_backend(
|
||||
transport.as_ref(),
|
||||
backend.clone(),
|
||||
Path::new("."),
|
||||
"打开知乎写文章页面",
|
||||
&CompatTaskContext::default(),
|
||||
WorkflowRoute::ZhihuArticleEntry,
|
||||
)
|
||||
.expect("ws-style backend should satisfy direct route execution");
|
||||
|
||||
assert_eq!(summary, "已进入知乎文章编辑器。");
|
||||
assert_eq!(
|
||||
backend.invocations(),
|
||||
vec![
|
||||
(
|
||||
Action::Navigate,
|
||||
json!({ "url": ZHIHU_CREATOR_URL }),
|
||||
ZHIHU_DOMAIN.to_string(),
|
||||
),
|
||||
(
|
||||
Action::Eval,
|
||||
json!({
|
||||
"script": load_browser_skill_script(
|
||||
"zhihu-navigate",
|
||||
"open_creator_entry.js",
|
||||
json!({ "desired_target": "article_editor" })
|
||||
)
|
||||
.expect("zhihu navigate script should load")
|
||||
}),
|
||||
ZHIHU_DOMAIN.to_string(),
|
||||
),
|
||||
(
|
||||
Action::Eval,
|
||||
json!({
|
||||
"script": load_browser_skill_script(
|
||||
"zhihu-write",
|
||||
"prepare_article_editor.js",
|
||||
json!({ "desired_mode": "draft" })
|
||||
)
|
||||
.expect("zhihu write script should load")
|
||||
}),
|
||||
ZHIHU_EDITOR_DOMAIN.to_string(),
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_route_with_browser_backend_keeps_bridge_style_article_entry_direct_route() {
|
||||
let transport = Arc::new(MockWorkflowTransport::new(vec![]));
|
||||
let backend = Arc::new(FakeBrowserBackend::new(vec![
|
||||
Ok(CommandOutput {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 1,
|
||||
},
|
||||
}),
|
||||
Ok(CommandOutput {
|
||||
seq: 2,
|
||||
success: true,
|
||||
data: json!({
|
||||
"text": {
|
||||
"status": "creator_entry_clicked",
|
||||
"current_url": "https://www.zhihu.com/creator",
|
||||
"next_url": ZHIHU_EDITOR_URL,
|
||||
}
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 1,
|
||||
},
|
||||
}),
|
||||
Ok(CommandOutput {
|
||||
seq: 3,
|
||||
success: true,
|
||||
data: json!({}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 1,
|
||||
},
|
||||
}),
|
||||
Ok(CommandOutput {
|
||||
seq: 4,
|
||||
success: true,
|
||||
data: json!({
|
||||
"text": {
|
||||
"status": "editor_ready",
|
||||
"current_url": ZHIHU_EDITOR_URL,
|
||||
}
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 1,
|
||||
},
|
||||
}),
|
||||
]));
|
||||
|
||||
let summary = execute_route_with_browser_backend(
|
||||
transport.as_ref(),
|
||||
backend.clone(),
|
||||
Path::new("."),
|
||||
"打开知乎写文章页面",
|
||||
&CompatTaskContext::default(),
|
||||
WorkflowRoute::ZhihuArticleEntry,
|
||||
)
|
||||
.expect("bridge-style backend should satisfy direct route execution");
|
||||
|
||||
assert_eq!(summary, "已进入知乎文章编辑器。");
|
||||
assert_eq!(
|
||||
backend.invocations(),
|
||||
vec![
|
||||
(
|
||||
Action::Navigate,
|
||||
json!({ "url": ZHIHU_CREATOR_URL }),
|
||||
ZHIHU_DOMAIN.to_string(),
|
||||
),
|
||||
(
|
||||
Action::Eval,
|
||||
json!({
|
||||
"script": load_browser_skill_script(
|
||||
"zhihu-navigate",
|
||||
"open_creator_entry.js",
|
||||
json!({ "desired_target": "article_editor" })
|
||||
)
|
||||
.expect("zhihu navigate script should load")
|
||||
}),
|
||||
ZHIHU_DOMAIN.to_string(),
|
||||
),
|
||||
(
|
||||
Action::Navigate,
|
||||
json!({ "url": ZHIHU_EDITOR_URL }),
|
||||
ZHIHU_EDITOR_DOMAIN.to_string(),
|
||||
),
|
||||
(
|
||||
Action::Eval,
|
||||
json!({
|
||||
"script": load_browser_skill_script(
|
||||
"zhihu-write",
|
||||
"prepare_article_editor.js",
|
||||
json!({ "desired_mode": "draft" })
|
||||
)
|
||||
.expect("zhihu write script should load")
|
||||
}),
|
||||
ZHIHU_EDITOR_DOMAIN.to_string(),
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_hotlist_items_skips_navigation_when_hot_page_is_already_readable() {
|
||||
let transport = Arc::new(MockWorkflowTransport::new(vec![
|
||||
@@ -771,7 +1072,8 @@ mod tests {
|
||||
..CompatTaskContext::default()
|
||||
};
|
||||
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_tool, 10, &task_context)
|
||||
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
|
||||
.expect("hotlist collection should succeed");
|
||||
|
||||
assert_eq!(items.len(), 2);
|
||||
@@ -824,7 +1126,8 @@ mod tests {
|
||||
..CompatTaskContext::default()
|
||||
};
|
||||
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_tool, 10, &task_context)
|
||||
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
|
||||
.expect("hotlist collection should succeed after readiness polling");
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
@@ -892,7 +1195,8 @@ mod tests {
|
||||
..CompatTaskContext::default()
|
||||
};
|
||||
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_tool, 10, &task_context)
|
||||
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
|
||||
.expect("hotlist collection should succeed after one navigation retry");
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
@@ -958,7 +1262,8 @@ mod tests {
|
||||
..CompatTaskContext::default()
|
||||
};
|
||||
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_tool, 10, &task_context)
|
||||
let browser_backend = PipeBrowserBackend::from_inner(browser_tool);
|
||||
let items = collect_hotlist_items(transport.as_ref(), &browser_backend, 10, &task_context)
|
||||
.expect("hotlist collection should succeed via extractor probe");
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
|
||||
Reference in New Issue
Block a user