feat: align browser callback runtime and export flows
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>
This commit is contained in:
267
src/compat/artifact_open.rs
Normal file
267
src/compat/artifact_open.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user