Consolidate the browser task runtime around the callback path, add safer artifact opening for Zhihu exports, and cover the new service/browser flows with focused tests and supporting docs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
268 lines
8.7 KiB
Rust
268 lines
8.7 KiB
Rust
use std::path::Path;
|
||
use std::process::Command;
|
||
|
||
use serde_json::{json, Value};
|
||
|
||
use crate::browser::BrowserBackend;
|
||
use crate::pipe::{Action, CommandOutput};
|
||
|
||
pub const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
|
||
pub const LOCAL_DASHBOARD_SOURCE: &str = "compat.workflow_executor";
|
||
pub const LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN: &str = "zhihu_hotlist_screen";
|
||
const DISABLE_POST_EXPORT_OPEN_ENV: &str = "SGCLAW_DISABLE_POST_EXPORT_OPEN";
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum PostExportOpen {
|
||
Opened,
|
||
Failed(String),
|
||
}
|
||
|
||
pub fn open_exported_xlsx(output_path: &Path) -> PostExportOpen {
|
||
open_exported_xlsx_with(output_path, launch_with_default_xlsx_app)
|
||
}
|
||
|
||
fn open_exported_xlsx_with<F>(output_path: &Path, opener: F) -> PostExportOpen
|
||
where
|
||
F: FnOnce(&Path) -> Result<(), String>,
|
||
{
|
||
if !output_path.exists() {
|
||
return PostExportOpen::Failed(format!(
|
||
"导出的 Excel 文件不存在:{}",
|
||
output_path.display()
|
||
));
|
||
}
|
||
|
||
match opener(output_path) {
|
||
Ok(()) => PostExportOpen::Opened,
|
||
Err(reason) => PostExportOpen::Failed(reason),
|
||
}
|
||
}
|
||
|
||
pub fn open_local_dashboard(
|
||
browser_backend: &dyn BrowserBackend,
|
||
output_path: &Path,
|
||
presentation_url: &str,
|
||
) -> PostExportOpen {
|
||
if !output_path.exists() {
|
||
return PostExportOpen::Failed(format!(
|
||
"生成的大屏文件不存在:{}",
|
||
output_path.display()
|
||
));
|
||
}
|
||
if presentation_url.trim().is_empty() {
|
||
return PostExportOpen::Failed("screen_html_export did not return presentation.url".to_string());
|
||
}
|
||
|
||
let params = json!({
|
||
"url": presentation_url,
|
||
"sgclaw_local_dashboard_open": {
|
||
"source": LOCAL_DASHBOARD_SOURCE,
|
||
"kind": LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN,
|
||
"output_path": output_path.to_string_lossy(),
|
||
"presentation_url": presentation_url,
|
||
}
|
||
});
|
||
|
||
match browser_backend.invoke(Action::Navigate, params, LOCAL_DASHBOARD_EXPECTED_DOMAIN) {
|
||
Ok(output) if output.success => PostExportOpen::Opened,
|
||
Ok(output) => PostExportOpen::Failed(command_output_reason(&output)),
|
||
Err(err) => PostExportOpen::Failed(err.to_string()),
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> {
|
||
if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() {
|
||
return Ok(());
|
||
}
|
||
|
||
let output = Command::new("cmd")
|
||
.args(["/C", "start", "", &output_path.display().to_string()])
|
||
.output()
|
||
.map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?;
|
||
if output.status.success() {
|
||
Ok(())
|
||
} else {
|
||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||
if stderr.is_empty() {
|
||
Err(format!(
|
||
"启动 Excel 默认程序失败:exit status {}",
|
||
output.status
|
||
))
|
||
} else {
|
||
Err(format!("启动 Excel 默认程序失败:{stderr}"))
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> {
|
||
if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() {
|
||
return Ok(());
|
||
}
|
||
|
||
let status = Command::new("open")
|
||
.arg(output_path)
|
||
.status()
|
||
.map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?;
|
||
if status.success() {
|
||
Ok(())
|
||
} else {
|
||
Err(format!("启动 Excel 默认程序失败:exit status {status}"))
|
||
}
|
||
}
|
||
|
||
#[cfg(all(unix, not(target_os = "macos")))]
|
||
fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> {
|
||
if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() {
|
||
return Ok(());
|
||
}
|
||
|
||
let status = Command::new("xdg-open")
|
||
.arg(output_path)
|
||
.status()
|
||
.map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?;
|
||
if status.success() {
|
||
Ok(())
|
||
} else {
|
||
Err(format!("启动 Excel 默认程序失败:exit status {status}"))
|
||
}
|
||
}
|
||
|
||
fn command_output_reason(output: &CommandOutput) -> String {
|
||
output
|
||
.data
|
||
.get("error")
|
||
.and_then(Value::as_str)
|
||
.or_else(|| output.data.get("message").and_then(Value::as_str))
|
||
.map(str::to_string)
|
||
.unwrap_or_else(|| output.data.to_string())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::collections::VecDeque;
|
||
use std::path::PathBuf;
|
||
use std::sync::Mutex;
|
||
|
||
use serde_json::json;
|
||
|
||
use crate::pipe::{ExecutionSurfaceMetadata, PipeError, Timing};
|
||
|
||
fn temp_file_path(name: &str) -> PathBuf {
|
||
let root = std::env::temp_dir().join(format!(
|
||
"sgclaw-artifact-open-{}-{}",
|
||
std::process::id(),
|
||
uuid::Uuid::new_v4()
|
||
));
|
||
std::fs::create_dir_all(&root).expect("temp root should exist");
|
||
root.join(name)
|
||
}
|
||
|
||
#[test]
|
||
fn open_exported_xlsx_with_passes_generated_path_to_launcher() {
|
||
let output_path = temp_file_path("zhihu-hotlist.xlsx");
|
||
std::fs::write(&output_path, b"xlsx").expect("xlsx fixture should be writable");
|
||
let seen = Mutex::new(None::<PathBuf>);
|
||
|
||
let result = open_exported_xlsx_with(&output_path, |path| {
|
||
*seen.lock().unwrap() = Some(path.to_path_buf());
|
||
Ok(())
|
||
});
|
||
|
||
assert!(matches!(result, PostExportOpen::Opened));
|
||
assert_eq!(seen.lock().unwrap().clone().unwrap(), output_path);
|
||
}
|
||
|
||
#[test]
|
||
fn open_exported_xlsx_with_reports_launcher_failure() {
|
||
let output_path = temp_file_path("zhihu-hotlist.xlsx");
|
||
std::fs::write(&output_path, b"xlsx").expect("xlsx fixture should be writable");
|
||
|
||
let result = open_exported_xlsx_with(&output_path, |_path| Err("launcher failed".to_string()));
|
||
|
||
assert!(matches!(result, PostExportOpen::Failed(reason) if reason.contains("launcher failed")));
|
||
}
|
||
|
||
#[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()),
|
||
}
|
||
}
|
||
}
|
||
|
||
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 open_local_dashboard_uses_exact_approved_marker_payload() {
|
||
let output_path = temp_file_path("zhihu-hotlist-screen.html");
|
||
std::fs::write(&output_path, "<html></html>").expect("dashboard fixture should be writable");
|
||
let presentation_url = format!("file:///{}", output_path.display().to_string().replace('\\', "/"));
|
||
let backend = FakeBrowserBackend::new(vec![Ok(CommandOutput {
|
||
seq: 1,
|
||
success: true,
|
||
data: json!({ "navigated": true }),
|
||
aom_snapshot: vec![],
|
||
timing: Timing {
|
||
queue_ms: 1,
|
||
exec_ms: 1,
|
||
},
|
||
})]);
|
||
|
||
let result = open_local_dashboard(&backend, &output_path, &presentation_url);
|
||
let invocations = backend.invocations.lock().unwrap().clone();
|
||
|
||
assert!(matches!(result, PostExportOpen::Opened));
|
||
assert_eq!(invocations.len(), 1);
|
||
assert_eq!(invocations[0].0, Action::Navigate);
|
||
assert_eq!(invocations[0].2, LOCAL_DASHBOARD_EXPECTED_DOMAIN.to_string());
|
||
assert_eq!(invocations[0].1["url"], json!(presentation_url));
|
||
assert_eq!(
|
||
invocations[0].1["sgclaw_local_dashboard_open"]["source"],
|
||
json!(LOCAL_DASHBOARD_SOURCE)
|
||
);
|
||
assert_eq!(
|
||
invocations[0].1["sgclaw_local_dashboard_open"]["kind"],
|
||
json!(LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN)
|
||
);
|
||
assert_eq!(
|
||
invocations[0].1["sgclaw_local_dashboard_open"]["output_path"],
|
||
json!(output_path.to_string_lossy().to_string())
|
||
);
|
||
assert_eq!(
|
||
invocations[0].1["sgclaw_local_dashboard_open"]["presentation_url"],
|
||
invocations[0].1["url"]
|
||
);
|
||
}
|
||
}
|