Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "nomifun-office"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nomifun-common.workspace = true
|
||||
nomifun-api-types.workspace = true
|
||||
nomifun-realtime.workspace = true
|
||||
nomifun-auth.workspace = true
|
||||
nomifun-file.workspace = true
|
||||
nomifun-runtime.workspace = true
|
||||
axum.workspace = true
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
which.workspace = true
|
||||
calamine.workspace = true
|
||||
sha1.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
dashmap.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
async-trait.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
rust_xlsxwriter.workspace = true
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
@@ -0,0 +1,351 @@
|
||||
use std::path::Path;
|
||||
|
||||
use calamine::{DataType, Reader, Sheets, open_workbook_auto};
|
||||
use nomifun_api_types::{
|
||||
CellCoord, CellRange, ConversionResultDto, ConversionTarget, DocumentConversionResponse, ExcelSheetData,
|
||||
ExcelWorkbookData,
|
||||
};
|
||||
use nomifun_runtime::Builder as CmdBuilder;
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::error::OfficeError;
|
||||
|
||||
pub struct ConversionService {
|
||||
officecli_path: Option<String>,
|
||||
}
|
||||
|
||||
impl ConversionService {
|
||||
pub fn new(officecli_path: Option<String>) -> Self {
|
||||
Self { officecli_path }
|
||||
}
|
||||
|
||||
pub async fn convert(
|
||||
&self,
|
||||
file_path: &str,
|
||||
target: ConversionTarget,
|
||||
) -> Result<DocumentConversionResponse, OfficeError> {
|
||||
let to_str = match target {
|
||||
ConversionTarget::Markdown => "markdown",
|
||||
ConversionTarget::ExcelJson => "excel-json",
|
||||
ConversionTarget::PptJson => "ppt-json",
|
||||
};
|
||||
|
||||
let result = match target {
|
||||
ConversionTarget::Markdown => self.word_to_markdown(file_path).await,
|
||||
ConversionTarget::ExcelJson => self.excel_to_json(file_path),
|
||||
ConversionTarget::PptJson => self.ppt_to_json(file_path).await,
|
||||
};
|
||||
|
||||
let result_dto = match result {
|
||||
Ok(data) => ConversionResultDto {
|
||||
success: true,
|
||||
data: Some(data),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => ConversionResultDto {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
Ok(DocumentConversionResponse {
|
||||
to: to_str.to_string(),
|
||||
result: result_dto,
|
||||
})
|
||||
}
|
||||
|
||||
async fn word_to_markdown(&self, file_path: &str) -> Result<Value, OfficeError> {
|
||||
validate_file_exists(file_path)?;
|
||||
|
||||
let pandoc = find_executable("pandoc");
|
||||
let pandoc_path = pandoc.ok_or_else(|| {
|
||||
OfficeError::ToolNotFound(
|
||||
"pandoc not installed. Install it via: brew install pandoc (macOS) \
|
||||
or apt-get install pandoc (Linux)"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut builder = CmdBuilder::clean_cli(&pandoc_path);
|
||||
builder.args(["-f", "docx", "-t", "markdown", "--wrap=none", file_path]);
|
||||
let output = builder
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| OfficeError::Conversion(format!("failed to run pandoc: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(OfficeError::Conversion(format!("pandoc failed: {stderr}")));
|
||||
}
|
||||
|
||||
let markdown = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
Ok(Value::String(markdown))
|
||||
}
|
||||
|
||||
fn excel_to_json(&self, file_path: &str) -> Result<Value, OfficeError> {
|
||||
validate_file_exists(file_path)?;
|
||||
|
||||
let mut workbook: Sheets<_> = open_workbook_auto(file_path)
|
||||
.map_err(|e| OfficeError::Conversion(format!("failed to open workbook: {e}")))?;
|
||||
|
||||
let sheet_names = workbook.sheet_names().to_vec();
|
||||
let mut sheets = Vec::with_capacity(sheet_names.len());
|
||||
|
||||
for name in &sheet_names {
|
||||
let range = workbook
|
||||
.worksheet_range(name)
|
||||
.map_err(|e| OfficeError::Conversion(format!("failed to read sheet '{name}': {e}")))?;
|
||||
|
||||
let data = convert_range_to_2d_array(&range);
|
||||
let merges = extract_merge_regions(&mut workbook, name);
|
||||
|
||||
sheets.push(ExcelSheetData {
|
||||
name: name.clone(),
|
||||
data,
|
||||
merges,
|
||||
images: None,
|
||||
});
|
||||
}
|
||||
|
||||
let workbook_data = ExcelWorkbookData { sheets };
|
||||
serde_json::to_value(workbook_data).map_err(OfficeError::Json)
|
||||
}
|
||||
|
||||
async fn ppt_to_json(&self, file_path: &str) -> Result<Value, OfficeError> {
|
||||
validate_file_exists(file_path)?;
|
||||
|
||||
let officecli = resolve_officecli(&self.officecli_path).await?;
|
||||
|
||||
let mut builder = CmdBuilder::clean_cli(&officecli);
|
||||
builder.args(["ppt2json", file_path]);
|
||||
let output = builder
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| OfficeError::Conversion(format!("failed to run officecli ppt2json: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(OfficeError::Conversion(format!("officecli ppt2json failed: {stderr}")));
|
||||
}
|
||||
|
||||
let json: Value = serde_json::from_slice(&output.stdout)
|
||||
.map_err(|e| OfficeError::Conversion(format!("failed to parse officecli ppt2json output: {e}")))?;
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_file_exists(file_path: &str) -> Result<(), OfficeError> {
|
||||
let path = Path::new(file_path);
|
||||
if !path.exists() {
|
||||
return Err(OfficeError::Conversion(format!("file not found: {file_path}")));
|
||||
}
|
||||
if !path.is_file() {
|
||||
return Err(OfficeError::Conversion(format!("not a file: {file_path}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn convert_range_to_2d_array(range: &calamine::Range<calamine::Data>) -> Vec<Vec<Value>> {
|
||||
let (rows, cols) = range.get_size();
|
||||
let mut data = Vec::with_capacity(rows);
|
||||
|
||||
for r in 0..rows {
|
||||
let mut row = Vec::with_capacity(cols);
|
||||
for c in 0..cols {
|
||||
let cell = &range[(r, c)];
|
||||
let value = cell_to_json_value(cell);
|
||||
row.push(value);
|
||||
}
|
||||
data.push(row);
|
||||
}
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
fn cell_to_json_value(cell: &calamine::Data) -> Value {
|
||||
if cell.is_empty() {
|
||||
return Value::Null;
|
||||
}
|
||||
if let Some(b) = cell.get_bool() {
|
||||
return Value::Bool(b);
|
||||
}
|
||||
if let Some(i) = cell.get_int() {
|
||||
return Value::Number(i.into());
|
||||
}
|
||||
if let Some(f) = cell.get_float() {
|
||||
return serde_json::Number::from_f64(f)
|
||||
.map(Value::Number)
|
||||
.unwrap_or(Value::Null);
|
||||
}
|
||||
if let Some(s) = cell.as_string() {
|
||||
return Value::String(s);
|
||||
}
|
||||
Value::Null
|
||||
}
|
||||
|
||||
fn extract_merge_regions<RS: std::io::Read + std::io::Seek>(
|
||||
workbook: &mut Sheets<RS>,
|
||||
sheet_name: &str,
|
||||
) -> Option<Vec<CellRange>> {
|
||||
let xlsx = match workbook {
|
||||
Sheets::Xlsx(wb) => wb,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if xlsx.load_merged_regions().is_err() {
|
||||
warn!("failed to load merged regions");
|
||||
return None;
|
||||
}
|
||||
|
||||
let regions = xlsx.merged_regions_by_sheet(sheet_name);
|
||||
if regions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ranges: Vec<CellRange> = regions
|
||||
.into_iter()
|
||||
.map(|(_, _, dim)| CellRange {
|
||||
s: CellCoord {
|
||||
r: dim.start.0 as usize,
|
||||
c: dim.start.1 as usize,
|
||||
},
|
||||
e: CellCoord {
|
||||
r: dim.end.0 as usize,
|
||||
c: dim.end.1 as usize,
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(ranges)
|
||||
}
|
||||
|
||||
fn find_executable(name: &str) -> Option<String> {
|
||||
which::which(name).ok().map(|p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
async fn resolve_officecli(configured_path: &Option<String>) -> Result<String, OfficeError> {
|
||||
if let Some(path) = configured_path
|
||||
&& Path::new(path).exists()
|
||||
{
|
||||
return Ok(path.clone());
|
||||
}
|
||||
|
||||
if let Some(found) = find_executable("officecli") {
|
||||
return Ok(found);
|
||||
}
|
||||
|
||||
Err(OfficeError::ToolNotFound(
|
||||
"officecli not installed. Install it to enable PPT → JSON conversion".into(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_file_exists_nonexistent() {
|
||||
let result = validate_file_exists("/nonexistent/file.xlsx");
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.to_string().contains("file not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_file_exists_is_directory() {
|
||||
let result = validate_file_exists("/tmp");
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.to_string().contains("not a file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_to_json_value_empty() {
|
||||
let cell = calamine::Data::Empty;
|
||||
assert_eq!(cell_to_json_value(&cell), Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_to_json_value_bool() {
|
||||
let cell = calamine::Data::Bool(true);
|
||||
assert_eq!(cell_to_json_value(&cell), Value::Bool(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_to_json_value_int() {
|
||||
let cell = calamine::Data::Int(42);
|
||||
assert_eq!(cell_to_json_value(&cell), serde_json::json!(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::approx_constant)] // 3.14 is test data, not an approximation of PI
|
||||
fn cell_to_json_value_float() {
|
||||
let cell = calamine::Data::Float(3.14);
|
||||
let val = cell_to_json_value(&cell);
|
||||
assert!(val.is_number());
|
||||
let n = val.as_f64().unwrap();
|
||||
assert!((n - 3.14).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_to_json_value_string() {
|
||||
let cell = calamine::Data::String("hello".to_string());
|
||||
assert_eq!(cell_to_json_value(&cell), Value::String("hello".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_range_empty() {
|
||||
let range = calamine::Range::<calamine::Data>::new((0, 0), (0, 0));
|
||||
let data = convert_range_to_2d_array(&range);
|
||||
assert_eq!(data.len(), 1);
|
||||
assert_eq!(data[0].len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_service_new() {
|
||||
let svc = ConversionService::new(None);
|
||||
assert!(svc.officecli_path.is_none());
|
||||
|
||||
let svc = ConversionService::new(Some("/usr/local/bin/officecli".into()));
|
||||
assert_eq!(svc.officecli_path.as_deref(), Some("/usr/local/bin/officecli"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn convert_excel_file_not_found() {
|
||||
let svc = ConversionService::new(None);
|
||||
let resp = svc
|
||||
.convert("/nonexistent/file.xlsx", ConversionTarget::ExcelJson)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!resp.result.success);
|
||||
assert!(resp.result.error.as_ref().unwrap().contains("file not found"));
|
||||
assert_eq!(resp.to, "excel-json");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn convert_word_file_not_found() {
|
||||
let svc = ConversionService::new(None);
|
||||
let resp = svc
|
||||
.convert("/nonexistent/file.docx", ConversionTarget::Markdown)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!resp.result.success);
|
||||
assert!(resp.result.error.as_ref().unwrap().contains("file not found"));
|
||||
assert_eq!(resp.to, "markdown");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn convert_ppt_file_not_found() {
|
||||
let svc = ConversionService::new(None);
|
||||
let resp = svc
|
||||
.convert("/nonexistent/file.pptx", ConversionTarget::PptJson)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!resp.result.success);
|
||||
assert!(resp.result.error.as_ref().unwrap().contains("file not found"));
|
||||
assert_eq!(resp.to, "ppt-json");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use nomifun_common::AppError;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum OfficeError {
|
||||
#[error("officecli not found")]
|
||||
OfficecliNotFound,
|
||||
|
||||
#[error("officecli install failed: {0}")]
|
||||
InstallFailed(String),
|
||||
|
||||
#[error("preview start failed: {0}")]
|
||||
StartFailed(String),
|
||||
|
||||
#[error("port readiness timeout for {0}")]
|
||||
PortTimeout(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("snapshot error: {0}")]
|
||||
Snapshot(String),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("conversion error: {0}")]
|
||||
Conversion(String),
|
||||
|
||||
#[error("external tool not found: {0}")]
|
||||
ToolNotFound(String),
|
||||
}
|
||||
|
||||
impl From<OfficeError> for AppError {
|
||||
fn from(err: OfficeError) -> Self {
|
||||
match err {
|
||||
OfficeError::OfficecliNotFound => AppError::BadRequest("officecli not found".into()),
|
||||
OfficeError::InstallFailed(msg) => AppError::Internal(format!("officecli install failed: {msg}")),
|
||||
OfficeError::StartFailed(msg) => AppError::Internal(format!("preview start failed: {msg}")),
|
||||
OfficeError::PortTimeout(path) => AppError::Timeout(format!("port readiness timeout for {path}")),
|
||||
OfficeError::Io(e) => AppError::Internal(format!("IO error: {e}")),
|
||||
OfficeError::Snapshot(msg) => AppError::Internal(format!("snapshot error: {msg}")),
|
||||
OfficeError::Json(e) => AppError::Internal(format!("JSON error: {e}")),
|
||||
OfficeError::Conversion(msg) => AppError::Internal(format!("conversion error: {msg}")),
|
||||
OfficeError::ToolNotFound(tool) => AppError::BadRequest(format!("{tool} is not installed")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn officecli_not_found_maps_to_bad_request() {
|
||||
let err: AppError = OfficeError::OfficecliNotFound.into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_failed_maps_to_internal() {
|
||||
let err: AppError = OfficeError::InstallFailed("npm error".into()).into();
|
||||
assert!(matches!(err, AppError::Internal(msg) if msg.contains("npm error")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_failed_maps_to_internal() {
|
||||
let err: AppError = OfficeError::StartFailed("spawn error".into()).into();
|
||||
assert!(matches!(err, AppError::Internal(msg) if msg.contains("spawn error")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_timeout_maps_to_timeout() {
|
||||
let err: AppError = OfficeError::PortTimeout("/a.docx".into()).into();
|
||||
assert!(matches!(err, AppError::Timeout(msg) if msg.contains("/a.docx")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn io_error_maps_to_internal() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
|
||||
let err: AppError = OfficeError::Io(io_err).into();
|
||||
assert!(matches!(err, AppError::Internal(msg) if msg.contains("file missing")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_error_maps_to_internal() {
|
||||
let err: AppError = OfficeError::Conversion("bad format".into()).into();
|
||||
assert!(matches!(err, AppError::Internal(msg) if msg.contains("bad format")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_not_found_maps_to_bad_request() {
|
||||
let err: AppError = OfficeError::ToolNotFound("pandoc".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(msg) if msg.contains("pandoc")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_messages() {
|
||||
assert_eq!(OfficeError::OfficecliNotFound.to_string(), "officecli not found");
|
||||
assert_eq!(
|
||||
OfficeError::InstallFailed("npm error".into()).to_string(),
|
||||
"officecli install failed: npm error"
|
||||
);
|
||||
assert_eq!(
|
||||
OfficeError::PortTimeout("/a.docx".into()).to_string(),
|
||||
"port readiness timeout for /a.docx"
|
||||
);
|
||||
assert_eq!(
|
||||
OfficeError::Conversion("bad data".into()).to_string(),
|
||||
"conversion error: bad data"
|
||||
);
|
||||
assert_eq!(
|
||||
OfficeError::ToolNotFound("pandoc".into()).to_string(),
|
||||
"external tool not found: pandoc"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Office document preview, format conversion, proxy, and snapshot management.
|
||||
pub mod conversion;
|
||||
pub mod error;
|
||||
pub mod port;
|
||||
pub mod proxy;
|
||||
pub mod routes;
|
||||
pub mod snapshot;
|
||||
pub mod star_office;
|
||||
pub mod state;
|
||||
pub mod types;
|
||||
pub mod watch_manager;
|
||||
|
||||
pub use conversion::ConversionService;
|
||||
pub use error::OfficeError;
|
||||
pub use proxy::{ProxyError, ProxyService};
|
||||
pub use routes::{office_proxy_routes, office_routes};
|
||||
pub use snapshot::SnapshotService;
|
||||
pub use star_office::StarOfficeDetector;
|
||||
pub use state::OfficeRouterState;
|
||||
pub use types::{DocType, OfficecliStatus};
|
||||
pub use watch_manager::{DefaultProcessSpawner, OfficecliWatchManager, ProcessHandle, ProcessSpawner};
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::net::TcpListener;
|
||||
|
||||
use crate::error::OfficeError;
|
||||
|
||||
pub fn allocate_port() -> Result<u16, OfficeError> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
pub async fn is_port_listening(port: u16) -> bool {
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn allocate_port_returns_nonzero() {
|
||||
let port = allocate_port().unwrap();
|
||||
assert!(port > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocate_port_returns_different_ports() {
|
||||
let p1 = allocate_port().unwrap();
|
||||
let p2 = allocate_port().unwrap();
|
||||
assert_ne!(p1, p2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_port_listening_false_for_unused_port() {
|
||||
let port = allocate_port().unwrap();
|
||||
assert!(!is_port_listening(port).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_port_listening_true_for_active_port() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
assert!(is_port_listening(port).await);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::header::{CONTENT_TYPE, HOST, HeaderMap, HeaderName, HeaderValue};
|
||||
|
||||
use crate::types::DocType;
|
||||
use crate::watch_manager::OfficecliWatchManager;
|
||||
|
||||
const PROXY_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
const HOP_BY_HOP_HEADERS: &[&str] = &[
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"cookie",
|
||||
"authorization",
|
||||
];
|
||||
|
||||
const NAVIGATION_GUARD_TEMPLATE: &str = r#"<script>
|
||||
(function(b){
|
||||
function rw(u){if(!u)return u;var s=String(u);var m=/^https?:\/\/(?:localhost|127\.0\.0\.1)(:\d+)?(\/.*)?$/.exec(s);if(m){var p=m[2]||'/';if(!p.startsWith(b))return b+(p==='/'?'/':p);}if(s==='/'||(s[0]==='/'&&s[1]!=='/'&&!s.startsWith(b)))return b+(s==='/'?'/':s);return s;}
|
||||
var _a=location.assign.bind(location),_r=location.replace.bind(location);
|
||||
location.assign=function(u){_a(rw(u));};location.replace=function(u){_r(rw(u));};
|
||||
var _ps=history.pushState.bind(history),_rs=history.replaceState.bind(history);
|
||||
history.pushState=function(s,t,u){_ps(s,t,u?rw(u):u);};history.replaceState=function(s,t,u){_rs(s,t,u?rw(u):u);};
|
||||
try{Object.defineProperty(location,'href',{set:function(v){_a(rw(v));},configurable:true});}catch(e){}
|
||||
document.addEventListener('click',function(e){var t=e.target;while(t&&t.tagName!=='A')t=t.parentElement;if(t&&t.tagName==='A'){var h=t.getAttribute('href');if(h&&(h[0]==='/'&&h[1]!=='/'&&!h.startsWith(b))){e.preventDefault();_a(b+h);}}},true);
|
||||
})('PROXY_BASE_PLACEHOLDER');
|
||||
</script>"#;
|
||||
|
||||
pub struct ProxyService {
|
||||
watch_manager: Arc<OfficecliWatchManager>,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProxyResponse {
|
||||
pub status: u16,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ProxyService {
|
||||
pub fn new(watch_manager: Arc<OfficecliWatchManager>) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(PROXY_TIMEOUT)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("failed to build proxy HTTP client");
|
||||
|
||||
Self { watch_manager, client }
|
||||
}
|
||||
|
||||
pub async fn forward(
|
||||
&self,
|
||||
port: u16,
|
||||
path: &str,
|
||||
doc_type: DocType,
|
||||
request_headers: &[(String, String)],
|
||||
) -> Result<ProxyResponse, ProxyError> {
|
||||
if !self.watch_manager.is_active_port(port, doc_type) {
|
||||
return Err(ProxyError::PortNotActive(port));
|
||||
}
|
||||
let proxy_base = format!("/api/{}/{}", doc_type.proxy_prefix(), port);
|
||||
self.forward_inner(port, path, &proxy_base, request_headers).await
|
||||
}
|
||||
|
||||
pub async fn forward_watch(
|
||||
&self,
|
||||
port: u16,
|
||||
path: &str,
|
||||
request_headers: &[(String, String)],
|
||||
) -> Result<ProxyResponse, ProxyError> {
|
||||
if !self.watch_manager.is_active_watch_port(port) {
|
||||
return Err(ProxyError::PortNotActive(port));
|
||||
}
|
||||
let proxy_base = format!("/api/office-watch-proxy/{port}");
|
||||
self.forward_inner(port, path, &proxy_base, request_headers).await
|
||||
}
|
||||
|
||||
async fn forward_inner(
|
||||
&self,
|
||||
port: u16,
|
||||
path: &str,
|
||||
proxy_base: &str,
|
||||
request_headers: &[(String, String)],
|
||||
) -> Result<ProxyResponse, ProxyError> {
|
||||
let target_url = build_target_url(port, path);
|
||||
|
||||
let mut req_headers = HeaderMap::new();
|
||||
req_headers.insert(
|
||||
HOST,
|
||||
HeaderValue::from_str(&format!("127.0.0.1:{port}"))
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("127.0.0.1")),
|
||||
);
|
||||
|
||||
for (key, value) in request_headers {
|
||||
let lower = key.to_lowercase();
|
||||
if is_hop_by_hop(&lower) {
|
||||
continue;
|
||||
}
|
||||
if lower == "host" {
|
||||
continue;
|
||||
}
|
||||
if let (Ok(name), Ok(val)) = (HeaderName::from_bytes(lower.as_bytes()), HeaderValue::from_str(value)) {
|
||||
req_headers.insert(name, val);
|
||||
}
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&target_url)
|
||||
.headers(req_headers)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
ProxyError::Timeout
|
||||
} else if e.is_connect() {
|
||||
ProxyError::ConnectionFailed(e.to_string())
|
||||
} else {
|
||||
ProxyError::RequestFailed(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
let resp_headers = resp.headers().clone();
|
||||
let body = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| ProxyError::RequestFailed(format!("failed to read response body: {e}")))?;
|
||||
|
||||
let mut out_headers = Vec::new();
|
||||
let is_html = is_html_content_type(&resp_headers);
|
||||
let mut body_bytes = body.to_vec();
|
||||
|
||||
for (name, value) in resp_headers.iter() {
|
||||
let name_str = name.as_str().to_lowercase();
|
||||
|
||||
if is_hop_by_hop(&name_str) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if name_str == "location"
|
||||
&& let Ok(loc) = value.to_str()
|
||||
{
|
||||
let rewritten = rewrite_location(loc, port, proxy_base);
|
||||
out_headers.push(("location".to_owned(), rewritten));
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_html && name_str == "content-length" {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(val_str) = value.to_str() {
|
||||
out_headers.push((name_str, val_str.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
out_headers.push(("x-frame-options".to_owned(), "SAMEORIGIN".to_owned()));
|
||||
|
||||
if is_html {
|
||||
body_bytes = inject_navigation_guard(&body_bytes, proxy_base);
|
||||
}
|
||||
|
||||
Ok(ProxyResponse {
|
||||
status,
|
||||
headers: out_headers,
|
||||
body: body_bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProxyError {
|
||||
#[error("port {0} is not an active preview port")]
|
||||
PortNotActive(u16),
|
||||
|
||||
#[error("proxy request timed out")]
|
||||
Timeout,
|
||||
|
||||
#[error("connection to preview server failed: {0}")]
|
||||
ConnectionFailed(String),
|
||||
|
||||
#[error("proxy request failed: {0}")]
|
||||
RequestFailed(String),
|
||||
}
|
||||
|
||||
impl From<ProxyError> for nomifun_common::AppError {
|
||||
fn from(err: ProxyError) -> Self {
|
||||
match err {
|
||||
ProxyError::PortNotActive(_) => nomifun_common::AppError::Forbidden(err.to_string()),
|
||||
ProxyError::Timeout => nomifun_common::AppError::Timeout(err.to_string()),
|
||||
ProxyError::ConnectionFailed(msg) => nomifun_common::AppError::BadGateway(msg),
|
||||
ProxyError::RequestFailed(msg) => nomifun_common::AppError::BadGateway(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_target_url(port: u16, path: &str) -> String {
|
||||
let normalized = if path.starts_with('/') {
|
||||
path.to_owned()
|
||||
} else {
|
||||
format!("/{path}")
|
||||
};
|
||||
format!("http://127.0.0.1:{port}{normalized}")
|
||||
}
|
||||
|
||||
fn is_hop_by_hop(header: &str) -> bool {
|
||||
HOP_BY_HOP_HEADERS.contains(&header)
|
||||
}
|
||||
|
||||
fn is_html_content_type(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|ct| ct.contains("text/html"))
|
||||
}
|
||||
|
||||
fn rewrite_location(location: &str, port: u16, proxy_base: &str) -> String {
|
||||
let pattern = format!("http://localhost:{port}");
|
||||
let pattern_ip = format!("http://127.0.0.1:{port}");
|
||||
|
||||
let rewritten = if location.starts_with(&pattern) {
|
||||
format!("{proxy_base}{}", &location[pattern.len()..])
|
||||
} else if location.starts_with(&pattern_ip) {
|
||||
format!("{proxy_base}{}", &location[pattern_ip.len()..])
|
||||
} else {
|
||||
location.to_owned()
|
||||
};
|
||||
|
||||
if rewritten == "/"
|
||||
|| (rewritten.starts_with('/') && !rewritten.starts_with("//") && !rewritten.starts_with(proxy_base))
|
||||
{
|
||||
if rewritten == "/" {
|
||||
format!("{proxy_base}/")
|
||||
} else {
|
||||
format!("{proxy_base}{rewritten}")
|
||||
}
|
||||
} else {
|
||||
rewritten
|
||||
}
|
||||
}
|
||||
|
||||
fn inject_navigation_guard(body: &[u8], proxy_base: &str) -> Vec<u8> {
|
||||
let html = String::from_utf8_lossy(body);
|
||||
let guard_script = NAVIGATION_GUARD_TEMPLATE.replace("PROXY_BASE_PLACEHOLDER", proxy_base);
|
||||
|
||||
let result = if let Some(pos) = find_head_tag_end(&html) {
|
||||
format!("{}{}{}", &html[..pos], guard_script, &html[pos..])
|
||||
} else {
|
||||
format!("{guard_script}{html}")
|
||||
};
|
||||
|
||||
result.into_bytes()
|
||||
}
|
||||
|
||||
fn find_head_tag_end(html: &str) -> Option<usize> {
|
||||
let lower = html.to_lowercase();
|
||||
let head_start = lower.find("<head")?;
|
||||
let tag_end = lower[head_start..].find('>')?;
|
||||
Some(head_start + tag_end + 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_target_url_with_leading_slash() {
|
||||
assert_eq!(
|
||||
build_target_url(8080, "/index.html"),
|
||||
"http://127.0.0.1:8080/index.html"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_target_url_without_leading_slash() {
|
||||
assert_eq!(build_target_url(8080, "index.html"), "http://127.0.0.1:8080/index.html");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_target_url_root() {
|
||||
assert_eq!(build_target_url(3000, "/"), "http://127.0.0.1:3000/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_target_url_empty_path() {
|
||||
assert_eq!(build_target_url(3000, ""), "http://127.0.0.1:3000/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_hop_by_hop_recognizes_all_headers() {
|
||||
for h in HOP_BY_HOP_HEADERS {
|
||||
assert!(is_hop_by_hop(h), "expected {h} to be hop-by-hop");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_hop_by_hop_rejects_normal_headers() {
|
||||
assert!(!is_hop_by_hop("content-type"));
|
||||
assert!(!is_hop_by_hop("accept"));
|
||||
assert!(!is_hop_by_hop("x-custom-header"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_html_content_type_detects_html() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
|
||||
assert!(is_html_content_type(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_html_content_type_rejects_json() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
assert!(!is_html_content_type(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_html_content_type_empty_headers() {
|
||||
let headers = HeaderMap::new();
|
||||
assert!(!is_html_content_type(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_location_localhost_absolute() {
|
||||
let result = rewrite_location("http://localhost:8080/new/path", 8080, "/api/ppt-proxy/8080");
|
||||
assert_eq!(result, "/api/ppt-proxy/8080/new/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_location_ip_absolute() {
|
||||
let result = rewrite_location("http://127.0.0.1:8080/new/path", 8080, "/api/ppt-proxy/8080");
|
||||
assert_eq!(result, "/api/ppt-proxy/8080/new/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_location_root_relative() {
|
||||
let result = rewrite_location("/foo/bar", 8080, "/api/ppt-proxy/8080");
|
||||
assert_eq!(result, "/api/ppt-proxy/8080/foo/bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_location_root_path() {
|
||||
let result = rewrite_location("/", 8080, "/api/ppt-proxy/8080");
|
||||
assert_eq!(result, "/api/ppt-proxy/8080/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_location_already_proxied() {
|
||||
let result = rewrite_location("/api/ppt-proxy/8080/existing", 8080, "/api/ppt-proxy/8080");
|
||||
assert_eq!(result, "/api/ppt-proxy/8080/existing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_location_external_url_unchanged() {
|
||||
let result = rewrite_location("https://example.com/path", 8080, "/api/ppt-proxy/8080");
|
||||
assert_eq!(result, "https://example.com/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_location_different_port_unchanged() {
|
||||
let result = rewrite_location("http://localhost:9999/path", 8080, "/api/ppt-proxy/8080");
|
||||
assert_eq!(result, "http://localhost:9999/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_location_localhost_root() {
|
||||
let result = rewrite_location("http://localhost:3000", 3000, "/api/office-watch-proxy/3000");
|
||||
assert_eq!(result, "/api/office-watch-proxy/3000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_guard_with_head_tag() {
|
||||
let html = b"<!DOCTYPE html><html><head><title>Test</title></head><body></body></html>";
|
||||
let result = inject_navigation_guard(html, "/api/ppt-proxy/8080");
|
||||
let result_str = String::from_utf8(result).unwrap();
|
||||
|
||||
assert!(result_str.contains("<head><script>"));
|
||||
assert!(result_str.contains("'/api/ppt-proxy/8080'"));
|
||||
assert!(result_str.contains("<title>Test</title>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_guard_with_head_attributes() {
|
||||
let html = b"<html><head lang=\"en\"><title>Test</title></head></html>";
|
||||
let result = inject_navigation_guard(html, "/api/ppt-proxy/8080");
|
||||
let result_str = String::from_utf8(result).unwrap();
|
||||
|
||||
assert!(result_str.contains("lang=\"en\"><script>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_guard_no_head_tag() {
|
||||
let html = b"<html><body>content</body></html>";
|
||||
let result = inject_navigation_guard(html, "/api/ppt-proxy/8080");
|
||||
let result_str = String::from_utf8(result).unwrap();
|
||||
|
||||
assert!(result_str.starts_with("<script>"));
|
||||
assert!(result_str.contains("content</body>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_guard_uppercase_head() {
|
||||
let html = b"<HTML><HEAD><TITLE>Test</TITLE></HEAD></HTML>";
|
||||
let result = inject_navigation_guard(html, "/api/ppt-proxy/8080");
|
||||
let result_str = String::from_utf8(result).unwrap();
|
||||
|
||||
assert!(result_str.contains("<HEAD><script>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_head_tag_end_normal() {
|
||||
assert_eq!(find_head_tag_end("<html><head><title>"), Some(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_head_tag_end_with_attrs() {
|
||||
let html = "<html><head lang=\"en\">";
|
||||
assert_eq!(find_head_tag_end(html), Some(html.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_head_tag_end_uppercase() {
|
||||
assert_eq!(find_head_tag_end("<html><HEAD>"), Some(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_head_tag_end_missing() {
|
||||
assert_eq!(find_head_tag_end("<html><body>"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_error_port_not_active_to_forbidden() {
|
||||
let err: nomifun_common::AppError = ProxyError::PortNotActive(8080).into();
|
||||
assert!(matches!(err, nomifun_common::AppError::Forbidden(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_error_timeout_to_timeout() {
|
||||
let err: nomifun_common::AppError = ProxyError::Timeout.into();
|
||||
assert!(matches!(err, nomifun_common::AppError::Timeout(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_error_connection_failed_to_bad_gateway() {
|
||||
let err: nomifun_common::AppError = ProxyError::ConnectionFailed("refused".into()).into();
|
||||
assert!(matches!(err, nomifun_common::AppError::BadGateway(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_error_request_failed_to_bad_gateway() {
|
||||
let err: nomifun_common::AppError = ProxyError::RequestFailed("network error".into()).into();
|
||||
assert!(matches!(err, nomifun_common::AppError::BadGateway(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_error_display() {
|
||||
assert_eq!(
|
||||
ProxyError::PortNotActive(8080).to_string(),
|
||||
"port 8080 is not an active preview port"
|
||||
);
|
||||
assert_eq!(ProxyError::Timeout.to_string(), "proxy request timed out");
|
||||
assert_eq!(
|
||||
ProxyError::ConnectionFailed("refused".into()).to_string(),
|
||||
"connection to preview server failed: refused"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_guard_contains_all_intercepts() {
|
||||
let guard = NAVIGATION_GUARD_TEMPLATE;
|
||||
assert!(guard.contains("location.assign"));
|
||||
assert!(guard.contains("location.replace"));
|
||||
assert!(guard.contains("history.pushState"));
|
||||
assert!(guard.contains("history.replaceState"));
|
||||
assert!(guard.contains("location,'href'"));
|
||||
assert!(guard.contains("addEventListener('click'"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
use axum::Router;
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use axum::extract::{Extension, Json, Path, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
|
||||
use nomifun_api_types::{
|
||||
ApiResponse, DetectStarOfficeRequest, DocumentConversionRequest, GetSnapshotContentRequest, ListSnapshotsRequest,
|
||||
PreviewSnapshotInfoDto, PreviewUrlResponse, SaveSnapshotRequest, SnapshotContentResponse, StarOfficeDetectResponse,
|
||||
StartPreviewRequest, StopPreviewRequest,
|
||||
};
|
||||
use nomifun_auth::CurrentUser;
|
||||
use nomifun_common::AppError;
|
||||
use nomifun_file::path_safety::validate_path_with_extra_root;
|
||||
|
||||
use crate::error::OfficeError;
|
||||
use crate::state::OfficeRouterState;
|
||||
use crate::types::DocType;
|
||||
|
||||
pub fn office_routes(state: OfficeRouterState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/word-preview/start", post(start_word_preview))
|
||||
.route("/api/word-preview/stop", post(stop_word_preview))
|
||||
.route("/api/excel-preview/start", post(start_excel_preview))
|
||||
.route("/api/excel-preview/stop", post(stop_excel_preview))
|
||||
.route("/api/ppt-preview/start", post(start_ppt_preview))
|
||||
.route("/api/ppt-preview/stop", post(stop_ppt_preview))
|
||||
.route("/api/preview-history/list", post(list_snapshots))
|
||||
.route("/api/preview-history/save", post(save_snapshot))
|
||||
.route("/api/preview-history/get-content", post(get_snapshot_content))
|
||||
.route("/api/star-office/detect", post(detect_star_office))
|
||||
.route("/api/document/convert", post(convert_document))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
pub fn office_proxy_routes(state: OfficeRouterState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/ppt-proxy/{port}", get(ppt_proxy))
|
||||
.route("/api/ppt-proxy/{port}/{*path}", get(ppt_proxy))
|
||||
.route("/api/office-watch-proxy/{port}", get(office_watch_proxy))
|
||||
.route("/api/office-watch-proxy/{port}/{*path}", get(office_watch_proxy))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ProxyPortPath {
|
||||
port: u16,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
// -- Preview start/stop handlers ------------------------------------------
|
||||
|
||||
async fn start_word_preview(
|
||||
State(state): State<OfficeRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<StartPreviewRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<PreviewUrlResponse>>, AppError> {
|
||||
start_preview(state, body, DocType::Word).await
|
||||
}
|
||||
|
||||
async fn stop_word_preview(
|
||||
State(state): State<OfficeRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<StopPreviewRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
stop_preview(state, body, DocType::Word).await
|
||||
}
|
||||
|
||||
async fn start_excel_preview(
|
||||
State(state): State<OfficeRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<StartPreviewRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<PreviewUrlResponse>>, AppError> {
|
||||
start_preview(state, body, DocType::Excel).await
|
||||
}
|
||||
|
||||
async fn stop_excel_preview(
|
||||
State(state): State<OfficeRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<StopPreviewRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
stop_preview(state, body, DocType::Excel).await
|
||||
}
|
||||
|
||||
async fn start_ppt_preview(
|
||||
State(state): State<OfficeRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<StartPreviewRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<PreviewUrlResponse>>, AppError> {
|
||||
start_preview(state, body, DocType::Ppt).await
|
||||
}
|
||||
|
||||
async fn stop_ppt_preview(
|
||||
State(state): State<OfficeRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<StopPreviewRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
stop_preview(state, body, DocType::Ppt).await
|
||||
}
|
||||
|
||||
async fn start_preview(
|
||||
state: OfficeRouterState,
|
||||
body: Result<Json<StartPreviewRequest>, JsonRejection>,
|
||||
doc_type: DocType,
|
||||
) -> Result<Json<ApiResponse<PreviewUrlResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let validated_path = validate_office_path(&state, &req.file_path, req.workspace.as_deref())?;
|
||||
let validated_path = validated_path.to_string_lossy().into_owned();
|
||||
|
||||
let result = state.watch_manager.start(&validated_path, doc_type).await;
|
||||
|
||||
let resp = match result {
|
||||
Ok(port) => {
|
||||
let url = format!("/api/{}/{}", doc_type.proxy_prefix(), port);
|
||||
PreviewUrlResponse { url, error: None }
|
||||
}
|
||||
Err(e) => PreviewUrlResponse {
|
||||
url: String::new(),
|
||||
error: Some(preview_error_code(&e).to_owned()),
|
||||
},
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
async fn stop_preview(
|
||||
state: OfficeRouterState,
|
||||
body: Result<Json<StopPreviewRequest>, JsonRejection>,
|
||||
doc_type: DocType,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
state.watch_manager.stop(&req.file_path, doc_type).await;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
// -- Snapshot handlers ----------------------------------------------------
|
||||
|
||||
async fn list_snapshots(
|
||||
State(state): State<OfficeRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<ListSnapshotsRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<Vec<PreviewSnapshotInfoDto>>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let snapshots = state.snapshot_service.list(&req.target).await?;
|
||||
Ok(Json(ApiResponse::ok(snapshots)))
|
||||
}
|
||||
|
||||
async fn save_snapshot(
|
||||
State(state): State<OfficeRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<SaveSnapshotRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<PreviewSnapshotInfoDto>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let info = state.snapshot_service.save(&req.target, &req.content).await?;
|
||||
Ok(Json(ApiResponse::ok(info)))
|
||||
}
|
||||
|
||||
async fn get_snapshot_content(
|
||||
State(state): State<OfficeRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<GetSnapshotContentRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<Option<SnapshotContentResponse>>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let result = state
|
||||
.snapshot_service
|
||||
.get_content(&req.target, &req.snapshot_id)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
// -- Star Office detection ------------------------------------------------
|
||||
|
||||
async fn detect_star_office(
|
||||
State(state): State<OfficeRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<DetectStarOfficeRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<StarOfficeDetectResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let url = state
|
||||
.star_office_detector
|
||||
.detect(req.preferred_url.as_deref(), req.force.unwrap_or(false), req.timeout_ms)
|
||||
.await;
|
||||
Ok(Json(ApiResponse::ok(StarOfficeDetectResponse { url })))
|
||||
}
|
||||
|
||||
// -- Document conversion --------------------------------------------------
|
||||
|
||||
async fn convert_document(
|
||||
State(state): State<OfficeRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<DocumentConversionRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<nomifun_api_types::DocumentConversionResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let validated_path = validate_office_path(&state, &req.file_path, req.workspace.as_deref())?;
|
||||
let resp = state
|
||||
.conversion_service
|
||||
.convert(validated_path.to_string_lossy().as_ref(), req.to)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
fn validate_office_path(
|
||||
state: &OfficeRouterState,
|
||||
file_path: &str,
|
||||
workspace: Option<&str>,
|
||||
) -> Result<PathBuf, AppError> {
|
||||
let allowed_roots: Vec<&FsPath> = state.allowed_roots.iter().map(PathBuf::as_path).collect();
|
||||
validate_path_with_extra_root(file_path, &allowed_roots, workspace.map(FsPath::new))
|
||||
}
|
||||
|
||||
fn preview_error_code(error: &OfficeError) -> &'static str {
|
||||
match error {
|
||||
OfficeError::OfficecliNotFound => "OFFICECLI_NOT_FOUND",
|
||||
OfficeError::InstallFailed(_) => "OFFICECLI_INSTALL_FAILED",
|
||||
OfficeError::PortTimeout(_) => "OFFICECLI_PORT_TIMEOUT",
|
||||
OfficeError::StartFailed(_)
|
||||
| OfficeError::Io(_)
|
||||
| OfficeError::Snapshot(_)
|
||||
| OfficeError::Json(_)
|
||||
| OfficeError::Conversion(_)
|
||||
| OfficeError::ToolNotFound(_) => "OFFICECLI_START_FAILED",
|
||||
}
|
||||
}
|
||||
|
||||
// -- Reverse proxy handlers -----------------------------------------------
|
||||
|
||||
async fn ppt_proxy(
|
||||
State(state): State<OfficeRouterState>,
|
||||
Path(params): Path<ProxyPortPath>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AppError> {
|
||||
let path = params.path.as_deref().unwrap_or("/");
|
||||
proxy_forward(state, params.port, path, DocType::Ppt, &headers).await
|
||||
}
|
||||
|
||||
async fn office_watch_proxy(
|
||||
State(state): State<OfficeRouterState>,
|
||||
Path(params): Path<ProxyPortPath>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AppError> {
|
||||
let path = params.path.as_deref().unwrap_or("/");
|
||||
let request_headers: Vec<(String, String)> = headers
|
||||
.iter()
|
||||
.filter_map(|(k, v)| v.to_str().ok().map(|val| (k.as_str().to_owned(), val.to_owned())))
|
||||
.collect();
|
||||
|
||||
let proxy_resp = state
|
||||
.proxy_service
|
||||
.forward_watch(params.port, path, &request_headers)
|
||||
.await?;
|
||||
|
||||
let status = StatusCode::from_u16(proxy_resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let mut response = axum::response::Response::builder().status(status);
|
||||
|
||||
for (key, value) in &proxy_resp.headers {
|
||||
response = response.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
Ok(response
|
||||
.body(axum::body::Body::from(proxy_resp.body))
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()))
|
||||
}
|
||||
|
||||
async fn proxy_forward(
|
||||
state: OfficeRouterState,
|
||||
port: u16,
|
||||
path: &str,
|
||||
doc_type: DocType,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Response, AppError> {
|
||||
let request_headers: Vec<(String, String)> = headers
|
||||
.iter()
|
||||
.filter_map(|(k, v)| v.to_str().ok().map(|val| (k.as_str().to_owned(), val.to_owned())))
|
||||
.collect();
|
||||
|
||||
let proxy_resp = state
|
||||
.proxy_service
|
||||
.forward(port, path, doc_type, &request_headers)
|
||||
.await?;
|
||||
|
||||
let status = StatusCode::from_u16(proxy_resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let mut response = axum::response::Response::builder().status(status);
|
||||
|
||||
for (key, value) in &proxy_resp.headers {
|
||||
response = response.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
Ok(response
|
||||
.body(axum::body::Body::from(proxy_resp.body))
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::conversion::ConversionService;
|
||||
use crate::error::OfficeError;
|
||||
use crate::proxy::ProxyService;
|
||||
use crate::snapshot::SnapshotService;
|
||||
use crate::star_office::StarOfficeDetector;
|
||||
use crate::state::OfficeRouterState;
|
||||
use crate::types::DocType;
|
||||
use crate::watch_manager::{OfficecliWatchManager, ProcessHandle, ProcessSpawner};
|
||||
|
||||
use super::{office_proxy_routes, office_routes};
|
||||
|
||||
#[test]
|
||||
fn office_routes_builds_without_panic() {
|
||||
let state = build_test_state();
|
||||
let _router = office_routes(state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn office_proxy_routes_builds_without_panic() {
|
||||
let state = build_test_state();
|
||||
let _router = office_proxy_routes(state);
|
||||
}
|
||||
|
||||
fn build_test_state() -> OfficeRouterState {
|
||||
struct NoopSpawner;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ProcessSpawner for NoopSpawner {
|
||||
async fn spawn_officecli(
|
||||
&self,
|
||||
_file_path: &str,
|
||||
_port: u16,
|
||||
_doc_type: DocType,
|
||||
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
|
||||
Err(OfficeError::OfficecliNotFound)
|
||||
}
|
||||
async fn install_officecli(&self) -> Result<(), OfficeError> {
|
||||
Err(OfficeError::InstallFailed("noop".into()))
|
||||
}
|
||||
async fn is_officecli_installed(&self) -> bool {
|
||||
false
|
||||
}
|
||||
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopBroadcaster;
|
||||
impl nomifun_realtime::EventBroadcaster for NoopBroadcaster {
|
||||
fn broadcast(&self, _msg: nomifun_api_types::WebSocketMessage<serde_json::Value>) {}
|
||||
}
|
||||
|
||||
let spawner = Arc::new(NoopSpawner);
|
||||
let bc: Arc<dyn nomifun_realtime::EventBroadcaster> = Arc::new(NoopBroadcaster);
|
||||
let wm = Arc::new(OfficecliWatchManager::new(spawner, bc));
|
||||
|
||||
let snapshot = Arc::new(SnapshotService::new(std::path::Path::new("/tmp/test")));
|
||||
let detector = Arc::new(StarOfficeDetector::new(reqwest::Client::new()));
|
||||
let conversion = Arc::new(ConversionService::new(None));
|
||||
let proxy = Arc::new(ProxyService::new(wm.clone()));
|
||||
|
||||
OfficeRouterState {
|
||||
watch_manager: wm,
|
||||
snapshot_service: snapshot,
|
||||
star_office_detector: detector,
|
||||
conversion_service: conversion,
|
||||
proxy_service: proxy,
|
||||
allowed_roots: vec![std::env::temp_dir()],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use nomifun_api_types::{PreviewHistoryTargetDto, PreviewSnapshotInfoDto, SnapshotContentResponse};
|
||||
use sha1::{Digest, Sha1};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::error::OfficeError;
|
||||
|
||||
const MAX_SNAPSHOTS: usize = 50;
|
||||
const SNAPSHOT_EXT: &str = ".md";
|
||||
const INDEX_FILE: &str = "index.json";
|
||||
|
||||
pub struct SnapshotService {
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl SnapshotService {
|
||||
pub fn new(data_dir: &Path) -> Self {
|
||||
Self {
|
||||
base_dir: data_dir.join("preview-history"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list(&self, target: &PreviewHistoryTargetDto) -> Result<Vec<PreviewSnapshotInfoDto>, OfficeError> {
|
||||
let dir = self.target_dir(target);
|
||||
let index_path = dir.join(INDEX_FILE);
|
||||
|
||||
if !index_path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let content = tokio::fs::read_to_string(&index_path).await?;
|
||||
let mut snapshots: Vec<PreviewSnapshotInfoDto> = serde_json::from_str(&content)?;
|
||||
snapshots.sort_by_key(|a| a.created_at);
|
||||
Ok(snapshots)
|
||||
}
|
||||
|
||||
pub async fn save(
|
||||
&self,
|
||||
target: &PreviewHistoryTargetDto,
|
||||
content: &str,
|
||||
) -> Result<PreviewSnapshotInfoDto, OfficeError> {
|
||||
let dir = self.target_dir(target);
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
|
||||
let now_ms = current_timestamp_ms();
|
||||
let random_suffix = random_hex();
|
||||
let id = format!("{now_ms}-{random_suffix}");
|
||||
let file_name = format!("{id}{SNAPSHOT_EXT}");
|
||||
let file_path = dir.join(&file_name);
|
||||
|
||||
tokio::fs::write(&file_path, content.as_bytes()).await?;
|
||||
|
||||
let label = format_label(now_ms);
|
||||
let info = PreviewSnapshotInfoDto {
|
||||
id,
|
||||
label,
|
||||
created_at: now_ms,
|
||||
size: content.len() as u64,
|
||||
content_type: target.content_type,
|
||||
file_name: target.file_name.clone(),
|
||||
file_path: target.file_path.clone(),
|
||||
};
|
||||
|
||||
let mut snapshots: Vec<PreviewSnapshotInfoDto> = self.read_index(&dir).await;
|
||||
snapshots.push(info.clone());
|
||||
|
||||
self.trim_and_write_index(&dir, &mut snapshots).await?;
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub async fn get_content(
|
||||
&self,
|
||||
target: &PreviewHistoryTargetDto,
|
||||
snapshot_id: &str,
|
||||
) -> Result<Option<SnapshotContentResponse>, OfficeError> {
|
||||
let dir = self.target_dir(target);
|
||||
let snapshots: Vec<PreviewSnapshotInfoDto> = self.read_index(&dir).await;
|
||||
|
||||
let Some(info) = snapshots.into_iter().find(|s| s.id == snapshot_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let file_path = dir.join(format!("{snapshot_id}{SNAPSHOT_EXT}"));
|
||||
if !file_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = tokio::fs::read_to_string(&file_path).await?;
|
||||
Ok(Some(SnapshotContentResponse {
|
||||
snapshot: info,
|
||||
content,
|
||||
}))
|
||||
}
|
||||
|
||||
fn target_dir(&self, target: &PreviewHistoryTargetDto) -> PathBuf {
|
||||
let hash = compute_target_hash(target);
|
||||
self.base_dir.join(hash)
|
||||
}
|
||||
|
||||
async fn read_index(&self, dir: &Path) -> Vec<PreviewSnapshotInfoDto> {
|
||||
let index_path = dir.join(INDEX_FILE);
|
||||
let Ok(content) = tokio::fs::read_to_string(&index_path).await else {
|
||||
return Vec::new();
|
||||
};
|
||||
serde_json::from_str(&content).unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn trim_and_write_index(
|
||||
&self,
|
||||
dir: &Path,
|
||||
snapshots: &mut Vec<PreviewSnapshotInfoDto>,
|
||||
) -> Result<(), OfficeError> {
|
||||
snapshots.sort_by_key(|a| a.created_at);
|
||||
|
||||
while snapshots.len() > MAX_SNAPSHOTS {
|
||||
if let Some(oldest) = snapshots.first() {
|
||||
let file_path = dir.join(format!("{}{SNAPSHOT_EXT}", oldest.id));
|
||||
if let Err(e) = tokio::fs::remove_file(&file_path).await {
|
||||
warn!(path = %file_path.display(), error = %e, "failed to remove old snapshot file");
|
||||
}
|
||||
}
|
||||
snapshots.remove(0);
|
||||
}
|
||||
|
||||
let index_path = dir.join(INDEX_FILE);
|
||||
let json = serde_json::to_string_pretty(snapshots)?;
|
||||
tokio::fs::write(&index_path, json.as_bytes()).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_target_hash(target: &PreviewHistoryTargetDto) -> String {
|
||||
let mut hasher = Sha1::new();
|
||||
|
||||
hasher.update(
|
||||
serde_json::to_value(target.content_type)
|
||||
.unwrap_or_default()
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.as_bytes(),
|
||||
);
|
||||
hasher.update(b"\0");
|
||||
|
||||
let fields: [Option<&str>; 5] = [
|
||||
target.file_path.as_deref(),
|
||||
target.workspace.as_deref(),
|
||||
target.file_name.as_deref(),
|
||||
target.title.as_deref(),
|
||||
target.language.as_deref(),
|
||||
];
|
||||
|
||||
for field in &fields {
|
||||
if let Some(val) = field {
|
||||
hasher.update(val.as_bytes());
|
||||
}
|
||||
hasher.update(b"\0");
|
||||
}
|
||||
// conversation_id is now an integer; fold its canonical decimal form into the
|
||||
// hash so the snapshot key still varies per conversation.
|
||||
if let Some(conv_id) = target.conversation_id {
|
||||
hasher.update(conv_id.to_string().as_bytes());
|
||||
}
|
||||
hasher.update(b"\0");
|
||||
|
||||
let result = hasher.finalize();
|
||||
result.iter().fold(String::with_capacity(40), |mut s, b| {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(s, "{b:02x}");
|
||||
s
|
||||
})
|
||||
}
|
||||
|
||||
fn current_timestamp_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
fn random_hex() -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.subsec_nanos();
|
||||
let pid = std::process::id();
|
||||
format!("{:08x}", nanos ^ pid)
|
||||
}
|
||||
|
||||
fn format_label(timestamp_ms: i64) -> String {
|
||||
let secs = timestamp_ms / 1000;
|
||||
let minutes = (secs / 60) % 60;
|
||||
let hours = (secs / 3600) % 24;
|
||||
let mut days = secs / 86400;
|
||||
|
||||
let mut year: i64 = 1970;
|
||||
loop {
|
||||
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
|
||||
if days < days_in_year {
|
||||
break;
|
||||
}
|
||||
days -= days_in_year;
|
||||
year += 1;
|
||||
}
|
||||
|
||||
let month_days: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
let mut month = 0;
|
||||
let mut day = days;
|
||||
for (i, &md) in month_days.iter().enumerate() {
|
||||
let md = if i == 1 && is_leap_year(year) { md + 1 } else { md };
|
||||
if day < md {
|
||||
month = i + 1;
|
||||
break;
|
||||
}
|
||||
day -= md;
|
||||
}
|
||||
|
||||
if month == 0 {
|
||||
month = 12;
|
||||
}
|
||||
|
||||
format!("{year:04}-{month:02}-{:02} {hours:02}:{minutes:02}", day + 1,)
|
||||
}
|
||||
|
||||
fn is_leap_year(year: i64) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nomifun_common::PreviewContentType;
|
||||
|
||||
#[test]
|
||||
fn compute_hash_deterministic() {
|
||||
let target = PreviewHistoryTargetDto {
|
||||
content_type: PreviewContentType::Markdown,
|
||||
file_path: Some("/a.md".into()),
|
||||
workspace: None,
|
||||
file_name: None,
|
||||
title: None,
|
||||
language: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
let h1 = compute_target_hash(&target);
|
||||
let h2 = compute_target_hash(&target);
|
||||
assert_eq!(h1, h2);
|
||||
assert_eq!(h1.len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_hash_different_targets() {
|
||||
let t1 = PreviewHistoryTargetDto {
|
||||
content_type: PreviewContentType::Markdown,
|
||||
file_path: Some("/a.md".into()),
|
||||
workspace: None,
|
||||
file_name: None,
|
||||
title: None,
|
||||
language: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
let t2 = PreviewHistoryTargetDto {
|
||||
content_type: PreviewContentType::Markdown,
|
||||
file_path: Some("/b.md".into()),
|
||||
workspace: None,
|
||||
file_name: None,
|
||||
title: None,
|
||||
language: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
assert_ne!(compute_target_hash(&t1), compute_target_hash(&t2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_hash_extra_fields_differ() {
|
||||
let t1 = PreviewHistoryTargetDto {
|
||||
content_type: PreviewContentType::Code,
|
||||
file_path: Some("/x.rs".into()),
|
||||
workspace: Some("/ws".into()),
|
||||
file_name: None,
|
||||
title: None,
|
||||
language: None,
|
||||
conversation_id: Some(1),
|
||||
};
|
||||
let t2 = PreviewHistoryTargetDto {
|
||||
content_type: PreviewContentType::Code,
|
||||
file_path: Some("/x.rs".into()),
|
||||
workspace: None,
|
||||
file_name: None,
|
||||
title: None,
|
||||
language: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
assert_ne!(compute_target_hash(&t1), compute_target_hash(&t2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_hex_returns_8_chars() {
|
||||
let hex = random_hex();
|
||||
assert_eq!(hex.len(), 8);
|
||||
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_ms_positive() {
|
||||
let ts = current_timestamp_ms();
|
||||
assert!(ts > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_label_known_timestamps() {
|
||||
// 2023-11-14 22:13 UTC
|
||||
assert_eq!(format_label(1700000000000), "2023-11-14 22:13");
|
||||
// 1970-01-01 00:00 UTC (epoch)
|
||||
assert_eq!(format_label(0), "1970-01-01 00:00");
|
||||
// 2000-02-29 00:00 UTC (leap year)
|
||||
assert_eq!(format_label(951782400000), "2000-02-29 00:00");
|
||||
// 2000-03-01 00:00 UTC (day after leap)
|
||||
assert_eq!(format_label(951868800000), "2000-03-01 00:00");
|
||||
// 2024-12-31 23:59 UTC
|
||||
assert_eq!(format_label(1735689540000), "2024-12-31 23:59");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_hash_field_boundary_collision() {
|
||||
// file_path="/foobar" vs file_path="/foo" + workspace="bar"
|
||||
let t1 = PreviewHistoryTargetDto {
|
||||
content_type: PreviewContentType::Markdown,
|
||||
file_path: Some("/foobar".into()),
|
||||
workspace: None,
|
||||
file_name: None,
|
||||
title: None,
|
||||
language: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
let t2 = PreviewHistoryTargetDto {
|
||||
content_type: PreviewContentType::Markdown,
|
||||
file_path: Some("/foo".into()),
|
||||
workspace: Some("bar".into()),
|
||||
file_name: None,
|
||||
title: None,
|
||||
language: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
assert_ne!(compute_target_hash(&t1), compute_target_hash(&t2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_hash_field_position_collision() {
|
||||
// file_name="x" vs title="x"
|
||||
let t1 = PreviewHistoryTargetDto {
|
||||
content_type: PreviewContentType::Code,
|
||||
file_path: None,
|
||||
workspace: None,
|
||||
file_name: Some("x".into()),
|
||||
title: None,
|
||||
language: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
let t2 = PreviewHistoryTargetDto {
|
||||
content_type: PreviewContentType::Code,
|
||||
file_path: None,
|
||||
workspace: None,
|
||||
file_name: None,
|
||||
title: Some("x".into()),
|
||||
language: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
assert_ne!(compute_target_hash(&t1), compute_target_hash(&t2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_list_empty_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
let result = svc.list(&target).await.unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_save_and_list() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
|
||||
let info = svc.save(&target, "# Hello").await.unwrap();
|
||||
assert!(!info.id.is_empty());
|
||||
assert_eq!(info.size, 7);
|
||||
assert_eq!(info.content_type, PreviewContentType::Markdown);
|
||||
|
||||
let list = svc.list(&target).await.unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].id, info.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_get_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Html, Some("/b.html"));
|
||||
|
||||
let info = svc.save(&target, "<h1>Hi</h1>").await.unwrap();
|
||||
let resp = svc.get_content(&target, &info.id).await.unwrap().unwrap();
|
||||
assert_eq!(resp.content, "<h1>Hi</h1>");
|
||||
assert_eq!(resp.snapshot.id, info.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_get_content_nonexistent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
|
||||
let resp = svc.get_content(&target, "nonexistent").await.unwrap();
|
||||
assert!(resp.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_trim_over_limit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Code, Some("/c.rs"));
|
||||
|
||||
for i in 0..52 {
|
||||
svc.save(&target, &format!("content-{i}")).await.unwrap();
|
||||
}
|
||||
|
||||
let list = svc.list(&target).await.unwrap();
|
||||
assert_eq!(list.len(), MAX_SNAPSHOTS);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_different_targets_isolated() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let t1 = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
let t2 = make_target(PreviewContentType::Markdown, Some("/b.md"));
|
||||
|
||||
svc.save(&t1, "content-a").await.unwrap();
|
||||
svc.save(&t2, "content-b1").await.unwrap();
|
||||
svc.save(&t2, "content-b2").await.unwrap();
|
||||
|
||||
let list1 = svc.list(&t1).await.unwrap();
|
||||
let list2 = svc.list(&t2).await.unwrap();
|
||||
assert_eq!(list1.len(), 1);
|
||||
assert_eq!(list2.len(), 2);
|
||||
}
|
||||
|
||||
fn make_target(content_type: PreviewContentType, file_path: Option<&str>) -> PreviewHistoryTargetDto {
|
||||
PreviewHistoryTargetDto {
|
||||
content_type,
|
||||
file_path: file_path.map(String::from),
|
||||
workspace: None,
|
||||
file_name: None,
|
||||
title: None,
|
||||
language: None,
|
||||
conversation_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::Instant;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 1000;
|
||||
const CACHE_HIT_TTL: Duration = Duration::from_secs(20);
|
||||
const CACHE_MISS_TTL: Duration = Duration::from_millis(1500);
|
||||
const MAX_CONCURRENT_WORKERS: usize = 6;
|
||||
const SCAN_RADIUS: u16 = 24;
|
||||
const KNOWN_PORTS: [u16; 2] = [19000, 18791];
|
||||
|
||||
const STATUS_MARKERS: [&str; 6] = ["idle", "writing", "researching", "executing", "syncing", "error"];
|
||||
const FEATURE_KEYWORDS: [&str; 3] = ["star office", "decorate room", "asset sidebar"];
|
||||
const EXCLUDE_KEYWORDS: [&str; 1] = ["openclaw control"];
|
||||
|
||||
struct DetectCache {
|
||||
url: Option<String>,
|
||||
cached_at: Instant,
|
||||
was_hit: bool,
|
||||
}
|
||||
|
||||
pub struct StarOfficeDetector {
|
||||
cache: Mutex<Option<DetectCache>>,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl StarOfficeDetector {
|
||||
pub fn new(client: reqwest::Client) -> Self {
|
||||
Self {
|
||||
cache: Mutex::new(None),
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn detect(&self, preferred_url: Option<&str>, force: bool, timeout_ms: Option<u64>) -> Option<String> {
|
||||
self.detect_inner(preferred_url, force, timeout_ms, true).await
|
||||
}
|
||||
|
||||
/// Probe only `preferred_url` without expanding to `KNOWN_PORTS` or the
|
||||
/// `±SCAN_RADIUS` neighborhood. Exists for deterministic tests that need
|
||||
/// to pin detection to a specific mock server; production callers should
|
||||
/// use [`detect`].
|
||||
pub async fn detect_exact(
|
||||
&self,
|
||||
preferred_url: Option<&str>,
|
||||
force: bool,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Option<String> {
|
||||
self.detect_inner(preferred_url, force, timeout_ms, false).await
|
||||
}
|
||||
|
||||
async fn detect_inner(
|
||||
&self,
|
||||
preferred_url: Option<&str>,
|
||||
force: bool,
|
||||
timeout_ms: Option<u64>,
|
||||
scan_neighbors: bool,
|
||||
) -> Option<String> {
|
||||
if !force {
|
||||
let cache = self.cache.lock().await;
|
||||
if let Some(ref c) = *cache {
|
||||
let ttl = if c.was_hit { CACHE_HIT_TTL } else { CACHE_MISS_TTL };
|
||||
if c.cached_at.elapsed() < ttl {
|
||||
tracing::debug!(cached_url = ?c.url, "returning cached star-office result");
|
||||
return c.url.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let candidates = build_candidate_urls(preferred_url, scan_neighbors);
|
||||
let timeout = Duration::from_millis(timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS));
|
||||
|
||||
tracing::debug!(count = candidates.len(), "scanning star-office candidate URLs");
|
||||
let result = self.scan_candidates(&candidates, timeout).await;
|
||||
|
||||
let mut cache = self.cache.lock().await;
|
||||
*cache = Some(DetectCache {
|
||||
url: result.clone(),
|
||||
cached_at: Instant::now(),
|
||||
was_hit: result.is_some(),
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn scan_candidates(&self, candidates: &[String], timeout: Duration) -> Option<String> {
|
||||
for chunk in candidates.chunks(MAX_CONCURRENT_WORKERS) {
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for url in chunk {
|
||||
let client = self.client.clone();
|
||||
let url = url.clone();
|
||||
set.spawn(async move {
|
||||
if check_health(&client, &url, timeout).await {
|
||||
Some(url)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
}
|
||||
while let Some(result) = set.join_next().await {
|
||||
if let Ok(Some(url)) = result {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn build_candidate_urls(preferred_url: Option<&str>, scan_neighbors: bool) -> Vec<String> {
|
||||
let mut seed_ports: Vec<u16> = Vec::new();
|
||||
|
||||
if let Some(url) = preferred_url
|
||||
&& let Some(port) = extract_port(url)
|
||||
{
|
||||
seed_ports.push(port);
|
||||
}
|
||||
|
||||
if !scan_neighbors {
|
||||
// Exact mode: skip KNOWN_PORTS and the ±SCAN_RADIUS expansion so the
|
||||
// detector talks to the caller-supplied URL only.
|
||||
return seed_ports.iter().map(|p| format!("http://localhost:{p}")).collect();
|
||||
}
|
||||
|
||||
for p in KNOWN_PORTS {
|
||||
if !seed_ports.contains(&p) {
|
||||
seed_ports.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
let mut expanded: Vec<u16> = Vec::new();
|
||||
for &base in &seed_ports {
|
||||
let start = base.saturating_sub(SCAN_RADIUS);
|
||||
let end = base.saturating_add(SCAN_RADIUS);
|
||||
for p in start..=end {
|
||||
if p > 0 && !expanded.contains(&p) {
|
||||
expanded.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut urls = Vec::with_capacity(expanded.len());
|
||||
for &p in &seed_ports {
|
||||
urls.push(format!("http://localhost:{p}"));
|
||||
}
|
||||
for &p in &expanded {
|
||||
if !seed_ports.contains(&p) {
|
||||
urls.push(format!("http://localhost:{p}"));
|
||||
}
|
||||
}
|
||||
|
||||
urls
|
||||
}
|
||||
|
||||
fn extract_port(url: &str) -> Option<u16> {
|
||||
let without_scheme = url.strip_prefix("http://").or_else(|| url.strip_prefix("https://"))?;
|
||||
let host_part = without_scheme.split('/').next()?;
|
||||
let port_str = host_part.rsplit(':').next()?;
|
||||
port_str.parse().ok()
|
||||
}
|
||||
|
||||
async fn check_health(client: &reqwest::Client, base_url: &str, timeout: Duration) -> bool {
|
||||
let health_url = format!("{base_url}/health");
|
||||
let resp = match client.get(&health_url).timeout(timeout).send().await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let status_url = format!("{base_url}/status");
|
||||
let resp = match client.get(&status_url).timeout(timeout).send().await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let body = match resp.text().await {
|
||||
Ok(t) => t.to_lowercase(),
|
||||
Err(_) => return false,
|
||||
};
|
||||
if !STATUS_MARKERS.iter().any(|m| body.contains(m)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let resp = match client.get(base_url).timeout(timeout).send().await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let body = match resp.text().await {
|
||||
Ok(t) => t.to_lowercase(),
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
FEATURE_KEYWORDS.iter().any(|k| body.contains(k)) && !EXCLUDE_KEYWORDS.iter().any(|k| body.contains(k))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_port_http() {
|
||||
assert_eq!(extract_port("http://localhost:19000"), Some(19000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_port_https() {
|
||||
assert_eq!(extract_port("https://localhost:8443"), Some(8443));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_port_with_path() {
|
||||
assert_eq!(extract_port("http://localhost:19000/star"), Some(19000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_port_no_scheme() {
|
||||
assert_eq!(extract_port("localhost:19000"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_port_no_port() {
|
||||
assert_eq!(extract_port("http://localhost"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_port_invalid_port() {
|
||||
assert_eq!(extract_port("http://localhost:abc"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_port_ipv4() {
|
||||
assert_eq!(extract_port("http://127.0.0.1:18791"), Some(18791));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_candidates_default_ports() {
|
||||
let urls = build_candidate_urls(None, true);
|
||||
assert!(urls[0] == "http://localhost:19000");
|
||||
assert!(urls[1] == "http://localhost:18791");
|
||||
let expected_count = count_unique_expanded_ports(&[19000, 18791]);
|
||||
assert_eq!(urls.len(), expected_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_candidates_preferred_first() {
|
||||
let urls = build_candidate_urls(Some("http://localhost:15000"), true);
|
||||
assert_eq!(urls[0], "http://localhost:15000");
|
||||
assert_eq!(urls[1], "http://localhost:19000");
|
||||
assert_eq!(urls[2], "http://localhost:18791");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_candidates_preferred_overlaps_known() {
|
||||
let urls = build_candidate_urls(Some("http://localhost:19000"), true);
|
||||
assert_eq!(urls[0], "http://localhost:19000");
|
||||
assert_eq!(urls[1], "http://localhost:18791");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_candidates_no_duplicates() {
|
||||
let urls = build_candidate_urls(Some("http://localhost:18800"), true);
|
||||
let unique: std::collections::HashSet<_> = urls.iter().collect();
|
||||
assert_eq!(urls.len(), unique.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_candidates_scan_radius_coverage() {
|
||||
let urls = build_candidate_urls(None, true);
|
||||
assert!(urls.contains(&"http://localhost:18976".to_string()));
|
||||
assert!(urls.contains(&"http://localhost:19024".to_string()));
|
||||
assert!(urls.contains(&"http://localhost:18767".to_string()));
|
||||
assert!(urls.contains(&"http://localhost:18815".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_candidates_preferred_without_port_ignored() {
|
||||
let urls = build_candidate_urls(Some("http://localhost"), true);
|
||||
assert_eq!(urls[0], "http://localhost:19000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_candidates_low_port_no_underflow() {
|
||||
let urls = build_candidate_urls(Some("http://localhost:10"), true);
|
||||
assert!(urls.iter().all(|u| {
|
||||
let p = extract_port(u).unwrap();
|
||||
p > 0
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_candidates_exact_mode_only_preferred() {
|
||||
let urls = build_candidate_urls(Some("http://localhost:55555"), false);
|
||||
assert_eq!(urls, vec!["http://localhost:55555"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_candidates_exact_mode_empty_when_no_preferred() {
|
||||
assert!(build_candidate_urls(None, false).is_empty());
|
||||
}
|
||||
|
||||
fn count_unique_expanded_ports(seeds: &[u16]) -> usize {
|
||||
let mut all = std::collections::HashSet::new();
|
||||
for &base in seeds {
|
||||
let start = base.saturating_sub(SCAN_RADIUS);
|
||||
let end = base.saturating_add(SCAN_RADIUS);
|
||||
for p in start..=end {
|
||||
if p > 0 {
|
||||
all.insert(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
all.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_ttl_constants() {
|
||||
assert_eq!(CACHE_HIT_TTL, Duration::from_secs(20));
|
||||
assert_eq!(CACHE_MISS_TTL, Duration::from_millis(1500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_timeout_constant() {
|
||||
assert_eq!(DEFAULT_TIMEOUT_MS, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_concurrent_workers_constant() {
|
||||
assert_eq!(MAX_CONCURRENT_WORKERS, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_radius_constant() {
|
||||
assert_eq!(SCAN_RADIUS, 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_markers_all_present() {
|
||||
let expected = ["idle", "writing", "researching", "executing", "syncing", "error"];
|
||||
assert_eq!(STATUS_MARKERS, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_keywords_all_present() {
|
||||
let expected = ["star office", "decorate room", "asset sidebar"];
|
||||
assert_eq!(FEATURE_KEYWORDS, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_keywords_contains_openclaw() {
|
||||
assert_eq!(EXCLUDE_KEYWORDS, ["openclaw control"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_no_service_returns_none() {
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let result = detector
|
||||
.detect_exact(Some("http://localhost:59999"), false, Some(50))
|
||||
.await;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_cache_miss_stored() {
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let _ = detector
|
||||
.detect_exact(Some("http://localhost:59998"), false, Some(50))
|
||||
.await;
|
||||
let cache = detector.cache.lock().await;
|
||||
let c = cache.as_ref().unwrap();
|
||||
assert!(c.url.is_none());
|
||||
assert!(!c.was_hit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::conversion::ConversionService;
|
||||
use crate::proxy::ProxyService;
|
||||
use crate::snapshot::SnapshotService;
|
||||
use crate::star_office::StarOfficeDetector;
|
||||
use crate::watch_manager::OfficecliWatchManager;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OfficeRouterState {
|
||||
pub watch_manager: Arc<OfficecliWatchManager>,
|
||||
pub snapshot_service: Arc<SnapshotService>,
|
||||
pub star_office_detector: Arc<StarOfficeDetector>,
|
||||
pub conversion_service: Arc<ConversionService>,
|
||||
pub proxy_service: Arc<ProxyService>,
|
||||
pub allowed_roots: Vec<PathBuf>,
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DocType {
|
||||
Word,
|
||||
Excel,
|
||||
Ppt,
|
||||
}
|
||||
|
||||
impl DocType {
|
||||
pub fn event_prefix(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Word => "word-preview",
|
||||
Self::Excel => "excel-preview",
|
||||
Self::Ppt => "ppt-preview",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn proxy_prefix(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Word | Self::Excel => "office-watch-proxy",
|
||||
Self::Ppt => "ppt-proxy",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn officecli_subcommand(&self) -> &'static str {
|
||||
"watch"
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DocType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Word => write!(f, "word"),
|
||||
Self::Excel => write!(f, "excel"),
|
||||
Self::Ppt => write!(f, "ppt"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OfficecliStatus {
|
||||
Starting,
|
||||
Installing,
|
||||
Ready,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn doc_type_event_prefix() {
|
||||
assert_eq!(DocType::Word.event_prefix(), "word-preview");
|
||||
assert_eq!(DocType::Excel.event_prefix(), "excel-preview");
|
||||
assert_eq!(DocType::Ppt.event_prefix(), "ppt-preview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_type_proxy_prefix() {
|
||||
assert_eq!(DocType::Word.proxy_prefix(), "office-watch-proxy");
|
||||
assert_eq!(DocType::Excel.proxy_prefix(), "office-watch-proxy");
|
||||
assert_eq!(DocType::Ppt.proxy_prefix(), "ppt-proxy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_type_officecli_subcommand() {
|
||||
assert_eq!(DocType::Word.officecli_subcommand(), "watch");
|
||||
assert_eq!(DocType::Excel.officecli_subcommand(), "watch");
|
||||
assert_eq!(DocType::Ppt.officecli_subcommand(), "watch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_type_display() {
|
||||
assert_eq!(DocType::Word.to_string(), "word");
|
||||
assert_eq!(DocType::Excel.to_string(), "excel");
|
||||
assert_eq!(DocType::Ppt.to_string(), "ppt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_type_serialize() {
|
||||
let cases = [
|
||||
(DocType::Word, "\"word\""),
|
||||
(DocType::Excel, "\"excel\""),
|
||||
(DocType::Ppt, "\"ppt\""),
|
||||
];
|
||||
for (dt, expected) in cases {
|
||||
assert_eq!(serde_json::to_string(&dt).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_type_deserialize() {
|
||||
let cases = [
|
||||
("\"word\"", DocType::Word),
|
||||
("\"excel\"", DocType::Excel),
|
||||
("\"ppt\"", DocType::Ppt),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
let parsed: DocType = serde_json::from_str(input).unwrap();
|
||||
assert_eq!(parsed, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_type_invalid_deserialize() {
|
||||
assert!(serde_json::from_str::<DocType>("\"pdf\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_type_eq_and_hash() {
|
||||
use std::collections::HashSet;
|
||||
let mut set = HashSet::new();
|
||||
set.insert(DocType::Word);
|
||||
set.insert(DocType::Excel);
|
||||
set.insert(DocType::Ppt);
|
||||
assert_eq!(set.len(), 3);
|
||||
assert!(set.contains(&DocType::Word));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn officecli_status_variants() {
|
||||
let statuses = [
|
||||
OfficecliStatus::Starting,
|
||||
OfficecliStatus::Installing,
|
||||
OfficecliStatus::Ready,
|
||||
OfficecliStatus::Error,
|
||||
];
|
||||
assert_eq!(statuses.len(), 4);
|
||||
assert_ne!(OfficecliStatus::Starting, OfficecliStatus::Ready);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use nomifun_api_types::{PreviewState, PreviewStatusEvent, WebSocketMessage};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use nomifun_runtime::Builder as CmdBuilder;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::error::OfficeError;
|
||||
use crate::port::{allocate_port, is_port_listening};
|
||||
use crate::types::DocType;
|
||||
|
||||
const POLL_INTERVAL_MS: u64 = 100;
|
||||
const POLL_MAX_ATTEMPTS: u32 = 150;
|
||||
const STOP_DELAY_MS: u64 = 500;
|
||||
const VERSION_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProcessSpawner trait — abstraction for child process management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ProcessSpawner: Send + Sync {
|
||||
async fn spawn_officecli(
|
||||
&self,
|
||||
file_path: &str,
|
||||
port: u16,
|
||||
doc_type: DocType,
|
||||
) -> Result<Box<dyn ProcessHandle>, OfficeError>;
|
||||
|
||||
async fn install_officecli(&self) -> Result<(), OfficeError>;
|
||||
|
||||
async fn is_officecli_installed(&self) -> bool;
|
||||
|
||||
async fn check_update(&self, doc_type: DocType) -> Result<(), OfficeError>;
|
||||
}
|
||||
|
||||
pub trait ProcessHandle: Send + Sync {
|
||||
fn kill(&self);
|
||||
fn is_alive(&self) -> bool;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WatchSession — per-file preview session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct WatchSession {
|
||||
port: u16,
|
||||
process: Box<dyn ProcessHandle>,
|
||||
file_path: String,
|
||||
doc_type: DocType,
|
||||
aborted: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OfficecliWatchManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct OfficecliWatchManager {
|
||||
sessions: DashMap<String, WatchSession>,
|
||||
spawner: Arc<dyn ProcessSpawner>,
|
||||
broadcaster: Arc<dyn EventBroadcaster>,
|
||||
last_version_check: Mutex<Option<std::time::Instant>>,
|
||||
}
|
||||
|
||||
impl OfficecliWatchManager {
|
||||
pub fn new(spawner: Arc<dyn ProcessSpawner>, broadcaster: Arc<dyn EventBroadcaster>) -> Self {
|
||||
Self {
|
||||
sessions: DashMap::new(),
|
||||
spawner,
|
||||
broadcaster,
|
||||
last_version_check: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(&self, file_path: &str, doc_type: DocType) -> Result<u16, OfficeError> {
|
||||
let resolved = resolve_path(file_path)?;
|
||||
let key = session_key(&resolved, doc_type);
|
||||
|
||||
if let Some(entry) = self.sessions.get(&key) {
|
||||
if !entry.aborted && entry.process.is_alive() {
|
||||
return Ok(entry.port);
|
||||
}
|
||||
drop(entry);
|
||||
self.sessions.remove(&key);
|
||||
}
|
||||
|
||||
self.broadcast_status(doc_type, PreviewState::Starting, None);
|
||||
|
||||
let result = self.try_start(&resolved, doc_type).await;
|
||||
|
||||
match &result {
|
||||
Ok(port) => {
|
||||
self.broadcast_status(doc_type, PreviewState::Ready, None);
|
||||
if doc_type == DocType::Ppt {
|
||||
self.maybe_check_update(doc_type).await;
|
||||
}
|
||||
Ok(*port)
|
||||
}
|
||||
Err(e) => {
|
||||
self.broadcast_status(doc_type, PreviewState::Error, Some(e.to_string()));
|
||||
Err(match e {
|
||||
OfficeError::OfficecliNotFound => OfficeError::OfficecliNotFound,
|
||||
OfficeError::InstallFailed(m) => OfficeError::InstallFailed(m.clone()),
|
||||
OfficeError::StartFailed(m) => OfficeError::StartFailed(m.clone()),
|
||||
OfficeError::PortTimeout(m) => OfficeError::PortTimeout(m.clone()),
|
||||
OfficeError::Io(io) => OfficeError::StartFailed(format!("IO error: {io}")),
|
||||
OfficeError::Snapshot(m) => OfficeError::StartFailed(m.clone()),
|
||||
OfficeError::Json(e) => OfficeError::StartFailed(format!("JSON error: {e}")),
|
||||
OfficeError::Conversion(m) => OfficeError::StartFailed(m.clone()),
|
||||
OfficeError::ToolNotFound(m) => OfficeError::StartFailed(m.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_start(&self, resolved: &str, doc_type: DocType) -> Result<u16, OfficeError> {
|
||||
let port = allocate_port()?;
|
||||
|
||||
let spawn_result = self.spawner.spawn_officecli(resolved, port, doc_type).await;
|
||||
|
||||
let process = match spawn_result {
|
||||
Ok(p) => p,
|
||||
Err(OfficeError::OfficecliNotFound) => {
|
||||
self.broadcast_status(doc_type, PreviewState::Installing, None);
|
||||
self.spawner.install_officecli().await?;
|
||||
self.spawner.spawn_officecli(resolved, port, doc_type).await?
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
self.poll_port_ready(port, resolved).await?;
|
||||
|
||||
let key = session_key(resolved, doc_type);
|
||||
self.sessions.insert(
|
||||
key,
|
||||
WatchSession {
|
||||
port,
|
||||
process,
|
||||
file_path: resolved.to_owned(),
|
||||
doc_type,
|
||||
aborted: false,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
async fn poll_port_ready(&self, port: u16, file_path: &str) -> Result<(), OfficeError> {
|
||||
for _ in 0..POLL_MAX_ATTEMPTS {
|
||||
if is_port_listening(port).await {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
|
||||
}
|
||||
Err(OfficeError::PortTimeout(file_path.to_owned()))
|
||||
}
|
||||
|
||||
pub async fn stop(&self, file_path: &str, doc_type: DocType) {
|
||||
let resolved = match resolve_path(file_path) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
let key = session_key(&resolved, doc_type);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(STOP_DELAY_MS)).await;
|
||||
|
||||
if let Some((_, session)) = self.sessions.remove(&key) {
|
||||
session.process.kill();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_all(&self) {
|
||||
for entry in self.sessions.iter() {
|
||||
tracing::debug!(
|
||||
file_path = %entry.value().file_path,
|
||||
doc_type = %entry.value().doc_type,
|
||||
"stopping preview session"
|
||||
);
|
||||
entry.value().process.kill();
|
||||
}
|
||||
self.sessions.clear();
|
||||
}
|
||||
|
||||
pub fn is_active_port(&self, port: u16, doc_type: DocType) -> bool {
|
||||
self.sessions
|
||||
.iter()
|
||||
.any(|entry| entry.port == port && entry.doc_type == doc_type)
|
||||
}
|
||||
|
||||
pub fn is_active_watch_port(&self, port: u16) -> bool {
|
||||
self.sessions
|
||||
.iter()
|
||||
.any(|entry| entry.port == port && matches!(entry.doc_type, DocType::Word | DocType::Excel))
|
||||
}
|
||||
|
||||
pub fn active_session_count(&self) -> usize {
|
||||
self.sessions.len()
|
||||
}
|
||||
|
||||
async fn maybe_check_update(&self, doc_type: DocType) {
|
||||
let mut last = self.last_version_check.lock().await;
|
||||
let should_check = match *last {
|
||||
Some(t) => t.elapsed() >= VERSION_CHECK_INTERVAL,
|
||||
None => true,
|
||||
};
|
||||
if should_check {
|
||||
*last = Some(std::time::Instant::now());
|
||||
drop(last);
|
||||
let spawner = Arc::clone(&self.spawner);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = spawner.check_update(doc_type).await {
|
||||
tracing::warn!("officecli version check failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn broadcast_status(&self, doc_type: DocType, state: PreviewState, message: Option<String>) {
|
||||
let event_name = format!("{}.status", doc_type.event_prefix());
|
||||
let payload = PreviewStatusEvent { state, message };
|
||||
let data = match serde_json::to_value(payload) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::error!("failed to serialize preview status: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.broadcaster.broadcast(WebSocketMessage::new(event_name, data));
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OfficecliWatchManager {
|
||||
fn drop(&mut self) {
|
||||
for entry in self.sessions.iter() {
|
||||
entry.value().process.kill();
|
||||
}
|
||||
self.sessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DefaultProcessSpawner — real implementation using tokio::process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct DefaultProcessSpawner;
|
||||
|
||||
struct TokioProcessHandle {
|
||||
child: Mutex<Option<tokio::process::Child>>,
|
||||
}
|
||||
|
||||
impl ProcessHandle for TokioProcessHandle {
|
||||
fn kill(&self) {
|
||||
if let Ok(mut guard) = self.child.try_lock() {
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn is_alive(&self) -> bool {
|
||||
if let Ok(mut guard) = self.child.try_lock()
|
||||
&& let Some(ref mut child) = *guard
|
||||
{
|
||||
return child.try_wait().ok().flatten().is_none();
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ProcessSpawner for DefaultProcessSpawner {
|
||||
async fn spawn_officecli(
|
||||
&self,
|
||||
file_path: &str,
|
||||
port: u16,
|
||||
_doc_type: DocType,
|
||||
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
|
||||
let mut builder = CmdBuilder::new("officecli");
|
||||
builder
|
||||
.arg("watch")
|
||||
.arg(file_path)
|
||||
.arg("--port")
|
||||
.arg(port.to_string())
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
let child = builder.spawn().map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
OfficeError::OfficecliNotFound
|
||||
} else {
|
||||
OfficeError::StartFailed(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Box::new(TokioProcessHandle {
|
||||
child: Mutex::new(Some(child)),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn install_officecli(&self) -> Result<(), OfficeError> {
|
||||
let mut builder = CmdBuilder::clean_cli("npm");
|
||||
builder.args(["install", "-g", "officecli"]);
|
||||
let output = builder
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| OfficeError::InstallFailed(e.to_string()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(OfficeError::InstallFailed(stderr.into_owned()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_officecli_installed(&self) -> bool {
|
||||
let mut builder = CmdBuilder::clean_cli("officecli");
|
||||
builder.arg("--version");
|
||||
builder.output().await.is_ok_and(|o| o.status.success())
|
||||
}
|
||||
|
||||
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
|
||||
let mut builder = CmdBuilder::clean_cli("npm");
|
||||
builder.args(["outdated", "-g", "officecli"]);
|
||||
let output = builder
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| OfficeError::StartFailed(e.to_string()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
tracing::info!("officecli update available, installing...");
|
||||
let mut install_builder = CmdBuilder::clean_cli("npm");
|
||||
install_builder.args(["install", "-g", "officecli@latest"]);
|
||||
let install = install_builder
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| OfficeError::InstallFailed(e.to_string()))?;
|
||||
|
||||
if !install.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&install.stderr);
|
||||
tracing::warn!("officecli update failed: {stderr}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn resolve_path(file_path: &str) -> Result<String, OfficeError> {
|
||||
let path = std::path::Path::new(file_path);
|
||||
let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
Ok(resolved.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
fn session_key(resolved_path: &str, doc_type: DocType) -> String {
|
||||
format!("{doc_type}:{resolved_path}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
|
||||
struct MockProcessHandle {
|
||||
alive: AtomicBool,
|
||||
killed: AtomicBool,
|
||||
}
|
||||
|
||||
impl MockProcessHandle {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
alive: AtomicBool::new(true),
|
||||
killed: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessHandle for MockProcessHandle {
|
||||
fn kill(&self) {
|
||||
self.alive.store(false, Ordering::SeqCst);
|
||||
self.killed.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn is_alive(&self) -> bool {
|
||||
self.alive.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
struct MockSpawner {
|
||||
installed: AtomicBool,
|
||||
spawn_count: AtomicU32,
|
||||
install_count: AtomicU32,
|
||||
update_count: AtomicU32,
|
||||
fail_spawn: AtomicBool,
|
||||
start_listener: AtomicBool,
|
||||
}
|
||||
|
||||
impl MockSpawner {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
installed: AtomicBool::new(true),
|
||||
spawn_count: AtomicU32::new(0),
|
||||
install_count: AtomicU32::new(0),
|
||||
update_count: AtomicU32::new(0),
|
||||
fail_spawn: AtomicBool::new(false),
|
||||
start_listener: AtomicBool::new(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ProcessSpawner for MockSpawner {
|
||||
async fn spawn_officecli(
|
||||
&self,
|
||||
_file_path: &str,
|
||||
port: u16,
|
||||
_doc_type: DocType,
|
||||
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
|
||||
self.spawn_count.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if self.fail_spawn.load(Ordering::SeqCst) {
|
||||
return Err(OfficeError::StartFailed("mock spawn failure".into()));
|
||||
}
|
||||
|
||||
if !self.installed.load(Ordering::SeqCst) {
|
||||
return Err(OfficeError::OfficecliNotFound);
|
||||
}
|
||||
|
||||
if self.start_listener.load(Ordering::SeqCst) {
|
||||
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||
.map_err(|e| OfficeError::StartFailed(e.to_string()))?;
|
||||
std::mem::forget(listener);
|
||||
}
|
||||
|
||||
Ok(Box::new(MockProcessHandle::new()))
|
||||
}
|
||||
|
||||
async fn install_officecli(&self) -> Result<(), OfficeError> {
|
||||
self.install_count.fetch_add(1, Ordering::SeqCst);
|
||||
self.installed.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_officecli_installed(&self) -> bool {
|
||||
self.installed.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
|
||||
self.update_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordingBroadcaster {
|
||||
events: std::sync::Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl RecordingBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: std::sync::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn events(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
|
||||
self.events.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for RecordingBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_manager(spawner: Arc<MockSpawner>, broadcaster: Arc<RecordingBroadcaster>) -> OfficecliWatchManager {
|
||||
OfficecliWatchManager::new(spawner, broadcaster)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_key_format() {
|
||||
let key = session_key("/path/to/doc.docx", DocType::Word);
|
||||
assert_eq!(key, "word:/path/to/doc.docx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_key_different_doc_types() {
|
||||
let k1 = session_key("/a.docx", DocType::Word);
|
||||
let k2 = session_key("/a.docx", DocType::Excel);
|
||||
assert_ne!(k1, k2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_creates_session() {
|
||||
let spawner = Arc::new(MockSpawner::new());
|
||||
let broadcaster = Arc::new(RecordingBroadcaster::new());
|
||||
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.docx");
|
||||
std::fs::write(&file, b"test").unwrap();
|
||||
|
||||
let port = mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
|
||||
assert!(port > 0);
|
||||
assert_eq!(mgr.active_session_count(), 1);
|
||||
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_reuses_existing_session() {
|
||||
let spawner = Arc::new(MockSpawner::new());
|
||||
let broadcaster = Arc::new(RecordingBroadcaster::new());
|
||||
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.docx");
|
||||
std::fs::write(&file, b"test").unwrap();
|
||||
|
||||
let p1 = mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
|
||||
let p2 = mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
|
||||
|
||||
assert_eq!(p1, p2);
|
||||
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_different_doc_types_independent() {
|
||||
let spawner = Arc::new(MockSpawner::new());
|
||||
let broadcaster = Arc::new(RecordingBroadcaster::new());
|
||||
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.docx");
|
||||
std::fs::write(&file, b"test").unwrap();
|
||||
|
||||
let p1 = mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
|
||||
let p2 = mgr.start(file.to_str().unwrap(), DocType::Excel).await.unwrap();
|
||||
|
||||
assert_ne!(p1, p2);
|
||||
assert_eq!(mgr.active_session_count(), 2);
|
||||
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_removes_session() {
|
||||
let spawner = Arc::new(MockSpawner::new());
|
||||
let broadcaster = Arc::new(RecordingBroadcaster::new());
|
||||
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.docx");
|
||||
std::fs::write(&file, b"test").unwrap();
|
||||
let path = file.to_str().unwrap();
|
||||
|
||||
mgr.start(path, DocType::Word).await.unwrap();
|
||||
assert_eq!(mgr.active_session_count(), 1);
|
||||
|
||||
mgr.stop(path, DocType::Word).await;
|
||||
assert_eq!(mgr.active_session_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_all_clears_everything() {
|
||||
let spawner = Arc::new(MockSpawner::new());
|
||||
let broadcaster = Arc::new(RecordingBroadcaster::new());
|
||||
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let f1 = dir.path().join("a.docx");
|
||||
let f2 = dir.path().join("b.xlsx");
|
||||
std::fs::write(&f1, b"a").unwrap();
|
||||
std::fs::write(&f2, b"b").unwrap();
|
||||
|
||||
mgr.start(f1.to_str().unwrap(), DocType::Word).await.unwrap();
|
||||
mgr.start(f2.to_str().unwrap(), DocType::Excel).await.unwrap();
|
||||
assert_eq!(mgr.active_session_count(), 2);
|
||||
|
||||
mgr.stop_all();
|
||||
assert_eq!(mgr.active_session_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_active_port_returns_true_for_active() {
|
||||
let spawner = Arc::new(MockSpawner::new());
|
||||
let broadcaster = Arc::new(RecordingBroadcaster::new());
|
||||
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.docx");
|
||||
std::fs::write(&file, b"test").unwrap();
|
||||
|
||||
let port = mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
|
||||
assert!(mgr.is_active_port(port, DocType::Word));
|
||||
assert!(!mgr.is_active_port(port, DocType::Ppt));
|
||||
assert!(!mgr.is_active_port(12345, DocType::Word));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_active_watch_port_accepts_word_and_excel() {
|
||||
let spawner = Arc::new(MockSpawner::new());
|
||||
let broadcaster = Arc::new(RecordingBroadcaster::new());
|
||||
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let word_file = dir.path().join("test.docx");
|
||||
let excel_file = dir.path().join("test.xlsx");
|
||||
let ppt_file = dir.path().join("test.pptx");
|
||||
std::fs::write(&word_file, b"w").unwrap();
|
||||
std::fs::write(&excel_file, b"e").unwrap();
|
||||
std::fs::write(&ppt_file, b"p").unwrap();
|
||||
|
||||
let word_port = mgr.start(word_file.to_str().unwrap(), DocType::Word).await.unwrap();
|
||||
let excel_port = mgr.start(excel_file.to_str().unwrap(), DocType::Excel).await.unwrap();
|
||||
let ppt_port = mgr.start(ppt_file.to_str().unwrap(), DocType::Ppt).await.unwrap();
|
||||
|
||||
assert!(mgr.is_active_watch_port(word_port));
|
||||
assert!(mgr.is_active_watch_port(excel_port));
|
||||
assert!(!mgr.is_active_watch_port(ppt_port));
|
||||
assert!(!mgr.is_active_watch_port(12345));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_install_when_not_found() {
|
||||
let spawner = Arc::new(MockSpawner::new());
|
||||
spawner.installed.store(false, Ordering::SeqCst);
|
||||
let broadcaster = Arc::new(RecordingBroadcaster::new());
|
||||
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.docx");
|
||||
std::fs::write(&file, b"test").unwrap();
|
||||
|
||||
let port = mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
|
||||
assert!(port > 0);
|
||||
assert_eq!(spawner.install_count.load(Ordering::SeqCst), 1);
|
||||
// First spawn fails (not installed), then install, then second spawn succeeds
|
||||
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn broadcasts_starting_and_ready() {
|
||||
let spawner = Arc::new(MockSpawner::new());
|
||||
let broadcaster = Arc::new(RecordingBroadcaster::new());
|
||||
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.docx");
|
||||
std::fs::write(&file, b"test").unwrap();
|
||||
|
||||
mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
|
||||
|
||||
let events = broadcaster.events();
|
||||
assert!(events.len() >= 2);
|
||||
assert_eq!(events[0].name, "word-preview.status");
|
||||
assert_eq!(events[0].data["state"], "starting");
|
||||
assert_eq!(events[1].name, "word-preview.status");
|
||||
assert_eq!(events[1].data["state"], "ready");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn broadcasts_installing_on_auto_install() {
|
||||
let spawner = Arc::new(MockSpawner::new());
|
||||
spawner.installed.store(false, Ordering::SeqCst);
|
||||
let broadcaster = Arc::new(RecordingBroadcaster::new());
|
||||
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.docx");
|
||||
std::fs::write(&file, b"test").unwrap();
|
||||
|
||||
mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
|
||||
|
||||
let events = broadcaster.events();
|
||||
let states: Vec<&str> = events.iter().filter_map(|e| e.data["state"].as_str()).collect();
|
||||
assert!(states.contains(&"starting"));
|
||||
assert!(states.contains(&"installing"));
|
||||
assert!(states.contains(&"ready"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn broadcasts_error_on_failure() {
|
||||
let spawner = Arc::new(MockSpawner::new());
|
||||
spawner.fail_spawn.store(true, Ordering::SeqCst);
|
||||
let broadcaster = Arc::new(RecordingBroadcaster::new());
|
||||
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.docx");
|
||||
std::fs::write(&file, b"test").unwrap();
|
||||
|
||||
let result = mgr.start(file.to_str().unwrap(), DocType::Word).await;
|
||||
assert!(result.is_err());
|
||||
|
||||
let events = broadcaster.events();
|
||||
let last = events.last().unwrap();
|
||||
assert_eq!(last.data["state"], "error");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn port_timeout_on_no_listener() {
|
||||
let spawner = Arc::new(MockSpawner::new());
|
||||
spawner.start_listener.store(false, Ordering::SeqCst);
|
||||
let broadcaster = Arc::new(RecordingBroadcaster::new());
|
||||
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.docx");
|
||||
std::fs::write(&file, b"test").unwrap();
|
||||
|
||||
let resolved = resolve_path(file.to_str().unwrap()).unwrap();
|
||||
let port = allocate_port().unwrap();
|
||||
let result = mgr.poll_port_ready(port, &resolved).await;
|
||||
assert!(matches!(result, Err(OfficeError::PortTimeout(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ppt_triggers_version_check() {
|
||||
let spawner = Arc::new(MockSpawner::new());
|
||||
let broadcaster = Arc::new(RecordingBroadcaster::new());
|
||||
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.pptx");
|
||||
std::fs::write(&file, b"test").unwrap();
|
||||
|
||||
mgr.start(file.to_str().unwrap(), DocType::Ppt).await.unwrap();
|
||||
|
||||
// Give the spawned task a moment
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(spawner.update_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn word_does_not_trigger_version_check() {
|
||||
let spawner = Arc::new(MockSpawner::new());
|
||||
let broadcaster = Arc::new(RecordingBroadcaster::new());
|
||||
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.docx");
|
||||
std::fs::write(&file, b"test").unwrap();
|
||||
|
||||
mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(spawner.update_count.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_nonexistent_is_no_op() {
|
||||
let spawner = Arc::new(MockSpawner::new());
|
||||
let broadcaster = Arc::new(RecordingBroadcaster::new());
|
||||
let mgr = make_manager(spawner, broadcaster);
|
||||
|
||||
mgr.stop("/nonexistent/file.docx", DocType::Word).await;
|
||||
assert_eq!(mgr.active_session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_path_normalizes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.docx");
|
||||
std::fs::write(&file, b"test").unwrap();
|
||||
|
||||
let resolved = resolve_path(file.to_str().unwrap()).unwrap();
|
||||
assert!(!resolved.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_path_nonexistent_returns_original() {
|
||||
let result = resolve_path("/nonexistent/path/test.docx").unwrap();
|
||||
assert_eq!(result, "/nonexistent/path/test.docx");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
use nomifun_api_types::ConversionTarget;
|
||||
use nomifun_office::ConversionService;
|
||||
use rust_xlsxwriter::{Format, Workbook};
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_simple_xlsx(dir: &TempDir) -> PathBuf {
|
||||
let path = dir.path().join("test.xlsx");
|
||||
let mut wb = Workbook::new();
|
||||
let sheet = wb.add_worksheet();
|
||||
sheet.set_name("Sheet1").unwrap();
|
||||
sheet.write_string(0, 0, "Name").unwrap();
|
||||
sheet.write_string(0, 1, "Age").unwrap();
|
||||
sheet.write_string(1, 0, "Alice").unwrap();
|
||||
sheet.write_number(1, 1, 30.0).unwrap();
|
||||
sheet.write_string(2, 0, "Bob").unwrap();
|
||||
sheet.write_number(2, 1, 25.0).unwrap();
|
||||
wb.save(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn create_multi_sheet_xlsx(dir: &TempDir) -> PathBuf {
|
||||
let path = dir.path().join("multi.xlsx");
|
||||
let mut wb = Workbook::new();
|
||||
|
||||
let s1 = wb.add_worksheet();
|
||||
s1.set_name("Users").unwrap();
|
||||
s1.write_string(0, 0, "Name").unwrap();
|
||||
s1.write_number(0, 1, 1.0).unwrap();
|
||||
|
||||
let s2 = wb.add_worksheet();
|
||||
s2.set_name("Products").unwrap();
|
||||
s2.write_string(0, 0, "Item").unwrap();
|
||||
s2.write_number(0, 1, 9.99).unwrap();
|
||||
|
||||
let s3 = wb.add_worksheet();
|
||||
s3.set_name("Empty").unwrap();
|
||||
|
||||
wb.save(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn create_xlsx_with_merges(dir: &TempDir) -> PathBuf {
|
||||
let path = dir.path().join("merged.xlsx");
|
||||
let mut wb = Workbook::new();
|
||||
let sheet = wb.add_worksheet();
|
||||
sheet.set_name("Merged").unwrap();
|
||||
let fmt = Format::new();
|
||||
sheet.merge_range(0, 0, 1, 2, "Merged Title", &fmt).unwrap();
|
||||
sheet.write_string(2, 0, "A").unwrap();
|
||||
sheet.write_string(2, 1, "B").unwrap();
|
||||
sheet.write_string(2, 2, "C").unwrap();
|
||||
wb.save(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[allow(clippy::approx_constant)] // 3.14 is test data, not an approximation of PI
|
||||
fn create_xlsx_with_types(dir: &TempDir) -> PathBuf {
|
||||
let path = dir.path().join("types.xlsx");
|
||||
let mut wb = Workbook::new();
|
||||
let sheet = wb.add_worksheet();
|
||||
sheet.set_name("Types").unwrap();
|
||||
sheet.write_string(0, 0, "text").unwrap();
|
||||
sheet.write_number(0, 1, 42.0).unwrap();
|
||||
sheet.write_number(0, 2, 3.14).unwrap();
|
||||
sheet.write_boolean(0, 3, true).unwrap();
|
||||
wb.save(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
// DC-1: Excel → JSON (normal)
|
||||
#[tokio::test]
|
||||
async fn dc1_excel_to_json_simple() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = create_simple_xlsx(&dir);
|
||||
let svc = ConversionService::new(None);
|
||||
|
||||
let resp = svc
|
||||
.convert(path.to_str().unwrap(), ConversionTarget::ExcelJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.to, "excel-json");
|
||||
assert!(resp.result.success);
|
||||
assert!(resp.result.error.is_none());
|
||||
|
||||
let data = resp.result.data.unwrap();
|
||||
let sheets = data["sheets"].as_array().unwrap();
|
||||
assert_eq!(sheets.len(), 1);
|
||||
assert_eq!(sheets[0]["name"], "Sheet1");
|
||||
|
||||
let rows = sheets[0]["data"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(rows[0][0], "Name");
|
||||
assert_eq!(rows[0][1], "Age");
|
||||
assert_eq!(rows[1][0], "Alice");
|
||||
assert_eq!(rows[1][1], 30.0);
|
||||
assert_eq!(rows[2][0], "Bob");
|
||||
assert_eq!(rows[2][1], 25.0);
|
||||
}
|
||||
|
||||
// DC-2: Excel → JSON (multiple sheets)
|
||||
#[tokio::test]
|
||||
async fn dc2_excel_to_json_multi_sheet() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = create_multi_sheet_xlsx(&dir);
|
||||
let svc = ConversionService::new(None);
|
||||
|
||||
let resp = svc
|
||||
.convert(path.to_str().unwrap(), ConversionTarget::ExcelJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(resp.result.success);
|
||||
let data = resp.result.data.unwrap();
|
||||
let sheets = data["sheets"].as_array().unwrap();
|
||||
assert_eq!(sheets.len(), 3);
|
||||
assert_eq!(sheets[0]["name"], "Users");
|
||||
assert_eq!(sheets[1]["name"], "Products");
|
||||
assert_eq!(sheets[2]["name"], "Empty");
|
||||
}
|
||||
|
||||
// DC-3: Excel → JSON (with merged cells)
|
||||
#[tokio::test]
|
||||
async fn dc3_excel_to_json_with_merges() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = create_xlsx_with_merges(&dir);
|
||||
let svc = ConversionService::new(None);
|
||||
|
||||
let resp = svc
|
||||
.convert(path.to_str().unwrap(), ConversionTarget::ExcelJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(resp.result.success);
|
||||
let data = resp.result.data.unwrap();
|
||||
let sheet = &data["sheets"][0];
|
||||
assert_eq!(sheet["name"], "Merged");
|
||||
|
||||
let merges = sheet["merges"].as_array().unwrap();
|
||||
assert!(!merges.is_empty());
|
||||
|
||||
let merge = &merges[0];
|
||||
assert_eq!(merge["s"]["r"], 0);
|
||||
assert_eq!(merge["s"]["c"], 0);
|
||||
assert_eq!(merge["e"]["r"], 1);
|
||||
assert_eq!(merge["e"]["c"], 2);
|
||||
}
|
||||
|
||||
// DC-4: Excel → JSON (file not found)
|
||||
#[tokio::test]
|
||||
async fn dc4_excel_to_json_file_not_found() {
|
||||
let svc = ConversionService::new(None);
|
||||
let resp = svc
|
||||
.convert("/nonexistent/file.xlsx", ConversionTarget::ExcelJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.to, "excel-json");
|
||||
assert!(!resp.result.success);
|
||||
assert!(resp.result.data.is_none());
|
||||
assert!(resp.result.error.as_ref().unwrap().contains("file not found"));
|
||||
}
|
||||
|
||||
// DC-6: Word → Markdown (pandoc not available)
|
||||
#[tokio::test]
|
||||
async fn dc6_word_to_markdown_file_not_found() {
|
||||
let svc = ConversionService::new(None);
|
||||
let resp = svc
|
||||
.convert("/nonexistent/file.docx", ConversionTarget::Markdown)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.to, "markdown");
|
||||
assert!(!resp.result.success);
|
||||
assert!(resp.result.error.as_ref().unwrap().contains("file not found"));
|
||||
}
|
||||
|
||||
// DC-8: PPT → JSON (officecli not available)
|
||||
#[tokio::test]
|
||||
async fn dc8_ppt_to_json_file_not_found() {
|
||||
let svc = ConversionService::new(None);
|
||||
let resp = svc
|
||||
.convert("/nonexistent/file.pptx", ConversionTarget::PptJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.to, "ppt-json");
|
||||
assert!(!resp.result.success);
|
||||
assert!(resp.result.error.as_ref().unwrap().contains("file not found"));
|
||||
}
|
||||
|
||||
// DC-8b: PPT → JSON (officecli not installed — configured path invalid and not in PATH)
|
||||
#[tokio::test]
|
||||
async fn dc8b_ppt_to_json_officecli_not_installed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("slides.pptx");
|
||||
std::fs::write(&path, b"fake pptx content").unwrap();
|
||||
|
||||
// Use a wrapper that ensures officecli is not found
|
||||
let svc = ConversionService::new(Some("/nonexistent/officecli".into()));
|
||||
let resp = svc
|
||||
.convert(path.to_str().unwrap(), ConversionTarget::PptJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Conversion should fail — either because officecli is not found or because
|
||||
// it fails to parse the fake file. Either way, success must be false.
|
||||
assert!(!resp.result.success);
|
||||
assert!(resp.result.error.is_some());
|
||||
}
|
||||
|
||||
// Excel cell type handling
|
||||
#[tokio::test]
|
||||
#[allow(clippy::approx_constant)] // 3.14 is test data, not an approximation of PI
|
||||
async fn excel_to_json_cell_types() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = create_xlsx_with_types(&dir);
|
||||
let svc = ConversionService::new(None);
|
||||
|
||||
let resp = svc
|
||||
.convert(path.to_str().unwrap(), ConversionTarget::ExcelJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(resp.result.success);
|
||||
let data = resp.result.data.unwrap();
|
||||
let row = &data["sheets"][0]["data"][0];
|
||||
|
||||
assert_eq!(row[0], "text");
|
||||
assert_eq!(row[1], 42.0);
|
||||
assert!((row[2].as_f64().unwrap() - 3.14).abs() < 0.01);
|
||||
assert_eq!(row[3], true);
|
||||
}
|
||||
|
||||
// Excel empty file
|
||||
#[tokio::test]
|
||||
async fn excel_to_json_empty_workbook() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("empty.xlsx");
|
||||
let mut wb = Workbook::new();
|
||||
wb.add_worksheet().set_name("Empty").unwrap();
|
||||
wb.save(&path).unwrap();
|
||||
|
||||
let svc = ConversionService::new(None);
|
||||
let resp = svc
|
||||
.convert(path.to_str().unwrap(), ConversionTarget::ExcelJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(resp.result.success);
|
||||
let data = resp.result.data.unwrap();
|
||||
let sheets = data["sheets"].as_array().unwrap();
|
||||
assert_eq!(sheets.len(), 1);
|
||||
assert_eq!(sheets[0]["name"], "Empty");
|
||||
}
|
||||
|
||||
// Response always returns success=true at convert() level (error wrapped in result)
|
||||
#[tokio::test]
|
||||
async fn convert_always_returns_ok_with_result_wrapper() {
|
||||
let svc = ConversionService::new(None);
|
||||
|
||||
let targets = [
|
||||
ConversionTarget::Markdown,
|
||||
ConversionTarget::ExcelJson,
|
||||
ConversionTarget::PptJson,
|
||||
];
|
||||
|
||||
for target in targets {
|
||||
let resp = svc.convert("/nonexistent/path", target).await;
|
||||
assert!(resp.is_ok());
|
||||
assert!(!resp.unwrap().result.success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_office::OfficeError;
|
||||
use nomifun_office::proxy::{ProxyError, ProxyService};
|
||||
use nomifun_office::types::DocType;
|
||||
use nomifun_office::watch_manager::{OfficecliWatchManager, ProcessHandle, ProcessSpawner};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test infrastructure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct MockProcessHandle {
|
||||
alive: AtomicBool,
|
||||
}
|
||||
|
||||
impl MockProcessHandle {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
alive: AtomicBool::new(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessHandle for MockProcessHandle {
|
||||
fn kill(&self) {
|
||||
self.alive.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn is_alive(&self) -> bool {
|
||||
self.alive.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
struct HttpMockSpawner {
|
||||
response_template: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ProcessSpawner for HttpMockSpawner {
|
||||
async fn spawn_officecli(
|
||||
&self,
|
||||
_file_path: &str,
|
||||
port: u16,
|
||||
_doc_type: DocType,
|
||||
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
|
||||
let resp = self.response_template.replace("__PORT__", &port.to_string());
|
||||
tokio::spawn(async move {
|
||||
let listener = TcpListener::bind(format!("127.0.0.1:{port}")).await.unwrap();
|
||||
for _ in 0..10 {
|
||||
if let Ok((mut stream, _)) = listener.accept().await {
|
||||
let resp = resp.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
|
||||
let _ = stream.write_all(resp.as_bytes()).await;
|
||||
let _ = stream.shutdown().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Box::new(MockProcessHandle::new()))
|
||||
}
|
||||
|
||||
async fn install_officecli(&self) -> Result<(), OfficeError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_officecli_installed(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct TcpOnlySpawner;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ProcessSpawner for TcpOnlySpawner {
|
||||
async fn spawn_officecli(
|
||||
&self,
|
||||
_file_path: &str,
|
||||
port: u16,
|
||||
_doc_type: DocType,
|
||||
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
|
||||
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||
.map_err(|e| OfficeError::StartFailed(e.to_string()))?;
|
||||
std::mem::forget(listener);
|
||||
Ok(Box::new(MockProcessHandle::new()))
|
||||
}
|
||||
|
||||
async fn install_officecli(&self) -> Result<(), OfficeError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_officecli_installed(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopBroadcaster;
|
||||
|
||||
impl EventBroadcaster for NoopBroadcaster {
|
||||
fn broadcast(&self, _event: WebSocketMessage<serde_json::Value>) {}
|
||||
}
|
||||
|
||||
fn build_http_response(status: u16, headers: &[(&str, &str)], body: &str) -> String {
|
||||
let status_text = match status {
|
||||
200 => "OK",
|
||||
302 => "Found",
|
||||
404 => "Not Found",
|
||||
_ => "Unknown",
|
||||
};
|
||||
|
||||
let mut resp = format!("HTTP/1.1 {status} {status_text}\r\n");
|
||||
for (k, v) in headers {
|
||||
resp.push_str(&format!("{k}: {v}\r\n"));
|
||||
}
|
||||
if !headers.iter().any(|(k, _)| k.to_lowercase() == "content-length") {
|
||||
resp.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||
}
|
||||
resp.push_str("\r\n");
|
||||
resp.push_str(body);
|
||||
resp
|
||||
}
|
||||
|
||||
async fn setup_proxy(doc_type: DocType, response_template: &str) -> (ProxyService, u16, tempfile::TempDir) {
|
||||
let spawner = HttpMockSpawner {
|
||||
response_template: response_template.to_owned(),
|
||||
};
|
||||
let mgr = Arc::new(OfficecliWatchManager::new(Arc::new(spawner), Arc::new(NoopBroadcaster)));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.docx");
|
||||
std::fs::write(&file, b"test").unwrap();
|
||||
|
||||
let port = mgr.start(file.to_str().unwrap(), doc_type).await.unwrap();
|
||||
let proxy = ProxyService::new(mgr);
|
||||
|
||||
(proxy, port, dir)
|
||||
}
|
||||
|
||||
async fn setup_ssrf_proxy(doc_type: DocType) -> (ProxyService, u16, tempfile::TempDir) {
|
||||
let mgr = Arc::new(OfficecliWatchManager::new(
|
||||
Arc::new(TcpOnlySpawner),
|
||||
Arc::new(NoopBroadcaster),
|
||||
));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.docx");
|
||||
std::fs::write(&file, b"test").unwrap();
|
||||
|
||||
let port = mgr.start(file.to_str().unwrap(), doc_type).await.unwrap();
|
||||
let proxy = ProxyService::new(mgr);
|
||||
|
||||
(proxy, port, dir)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RP-2: PPT proxy SSRF protection — inactive port rejected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp2_ppt_proxy_ssrf_rejects_inactive_port() {
|
||||
let (proxy, _active_port, _dir) = setup_ssrf_proxy(DocType::Ppt).await;
|
||||
|
||||
let result = proxy.forward(9999, "/index.html", DocType::Ppt, &[]).await;
|
||||
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, ProxyError::PortNotActive(9999)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RP-4: Office watch proxy SSRF protection — inactive port rejected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp4_office_watch_proxy_ssrf_rejects_inactive_port() {
|
||||
let (proxy, _active_port, _dir) = setup_ssrf_proxy(DocType::Word).await;
|
||||
|
||||
let result = proxy.forward_watch(9999, "/", &[]).await;
|
||||
|
||||
assert!(matches!(result.unwrap_err(), ProxyError::PortNotActive(9999)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSRF: wrong doc_type rejected even when port is active
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn ssrf_wrong_doc_type_rejected() {
|
||||
let (proxy, active_port, _dir) = setup_ssrf_proxy(DocType::Word).await;
|
||||
|
||||
let result = proxy.forward(active_port, "/index.html", DocType::Ppt, &[]).await;
|
||||
|
||||
assert!(matches!(result.unwrap_err(), ProxyError::PortNotActive(_)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// H-1-13.8 fix: forward_watch accepts Excel session ports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn forward_watch_accepts_excel_session_port() {
|
||||
let response = build_http_response(200, &[("Content-Type", "text/plain")], "Excel preview");
|
||||
let (proxy, port, _dir) = setup_proxy(DocType::Excel, &response).await;
|
||||
|
||||
let result = proxy.forward_watch(port, "/", &[]).await.unwrap();
|
||||
|
||||
assert_eq!(result.status, 200);
|
||||
let body = String::from_utf8(result.body).unwrap();
|
||||
assert!(body.contains("Excel preview"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// forward_watch accepts Word session ports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn forward_watch_accepts_word_session_port() {
|
||||
let response = build_http_response(200, &[("Content-Type", "text/plain")], "Word preview");
|
||||
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await;
|
||||
|
||||
let result = proxy.forward_watch(port, "/", &[]).await.unwrap();
|
||||
|
||||
assert_eq!(result.status, 200);
|
||||
let body = String::from_utf8(result.body).unwrap();
|
||||
assert!(body.contains("Word preview"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// forward_watch rejects PPT session ports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn forward_watch_rejects_ppt_session_port() {
|
||||
let (proxy, ppt_port, _dir) = setup_ssrf_proxy(DocType::Ppt).await;
|
||||
|
||||
let result = proxy.forward_watch(ppt_port, "/", &[]).await;
|
||||
|
||||
assert!(matches!(result.unwrap_err(), ProxyError::PortNotActive(_)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RP-1 / RP-3: Proxy forwards plain text response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp1_rp3_proxy_forwards_plain_text() {
|
||||
let response = build_http_response(200, &[("Content-Type", "text/plain")], "Hello from preview");
|
||||
let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response).await;
|
||||
|
||||
let result = proxy.forward(port, "/index.html", DocType::Ppt, &[]).await.unwrap();
|
||||
|
||||
assert_eq!(result.status, 200);
|
||||
let body = String::from_utf8(result.body).unwrap();
|
||||
assert!(body.contains("Hello from preview"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RP-5: HTML injection — navigation guard script injected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp5_proxy_injects_navigation_guard_in_html() {
|
||||
let html_body = "<html><head><title>Preview</title></head><body>Content</body></html>";
|
||||
let response = build_http_response(200, &[("Content-Type", "text/html")], html_body);
|
||||
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await;
|
||||
|
||||
let result = proxy.forward(port, "/", DocType::Word, &[]).await.unwrap();
|
||||
|
||||
assert_eq!(result.status, 200);
|
||||
let body = String::from_utf8(result.body).unwrap();
|
||||
assert!(body.contains("<script>"), "should inject navigation guard script");
|
||||
assert!(
|
||||
body.contains(&format!("'/api/office-watch-proxy/{port}'")),
|
||||
"guard should reference correct proxy base path"
|
||||
);
|
||||
assert!(
|
||||
body.contains("<title>Preview</title>"),
|
||||
"should preserve original HTML content"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RP-5b: Non-HTML response should NOT inject script
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp5b_proxy_does_not_inject_in_json() {
|
||||
let response = build_http_response(200, &[("Content-Type", "application/json")], r#"{"ok":true}"#);
|
||||
let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response).await;
|
||||
|
||||
let result = proxy.forward(port, "/api/data", DocType::Ppt, &[]).await.unwrap();
|
||||
|
||||
let body = String::from_utf8(result.body).unwrap();
|
||||
assert!(!body.contains("<script>"), "should not inject script in JSON responses");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RP-7: Hop-by-hop header stripping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp7_proxy_strips_hop_by_hop_headers() {
|
||||
let response = build_http_response(
|
||||
200,
|
||||
&[
|
||||
("Content-Type", "text/plain"),
|
||||
("Connection", "keep-alive"),
|
||||
("Keep-Alive", "timeout=5"),
|
||||
("X-Custom", "preserved"),
|
||||
],
|
||||
"body",
|
||||
);
|
||||
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await;
|
||||
|
||||
let result = proxy.forward(port, "/", DocType::Word, &[]).await.unwrap();
|
||||
|
||||
let header_names: Vec<&str> = result.headers.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert!(
|
||||
!header_names.contains(&"connection"),
|
||||
"connection header should be stripped"
|
||||
);
|
||||
assert!(
|
||||
!header_names.contains(&"keep-alive"),
|
||||
"keep-alive header should be stripped"
|
||||
);
|
||||
assert!(header_names.contains(&"x-custom"), "custom header should be preserved");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// X-Frame-Options set to SAMEORIGIN
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_sets_x_frame_options_sameorigin() {
|
||||
let response = build_http_response(200, &[("Content-Type", "text/plain")], "body");
|
||||
let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response).await;
|
||||
|
||||
let result = proxy.forward(port, "/", DocType::Ppt, &[]).await.unwrap();
|
||||
|
||||
let xfo = result
|
||||
.headers
|
||||
.iter()
|
||||
.find(|(k, _)| k == "x-frame-options")
|
||||
.map(|(_, v)| v.as_str());
|
||||
assert_eq!(xfo, Some("SAMEORIGIN"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML content-length stripped after injection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_removes_content_length_for_html() {
|
||||
let html_body = "<html><head></head><body></body></html>";
|
||||
let response = build_http_response(200, &[("Content-Type", "text/html")], html_body);
|
||||
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await;
|
||||
|
||||
let result = proxy.forward(port, "/", DocType::Word, &[]).await.unwrap();
|
||||
|
||||
let has_cl = result.headers.iter().any(|(k, _)| k == "content-length");
|
||||
assert!(
|
||||
!has_cl,
|
||||
"content-length should be stripped for HTML responses (body size changed after injection)"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Non-HTML content-length preserved
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_preserves_content_length_for_non_html() {
|
||||
let response = build_http_response(200, &[("Content-Type", "application/json")], r#"{"ok":true}"#);
|
||||
let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response).await;
|
||||
|
||||
let result = proxy.forward(port, "/api/data", DocType::Ppt, &[]).await.unwrap();
|
||||
|
||||
let has_cl = result.headers.iter().any(|(k, _)| k == "content-length");
|
||||
assert!(has_cl, "content-length should be preserved for non-HTML");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RP-6: Location rewriting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp6_proxy_rewrites_location_header() {
|
||||
let response_template = build_http_response(
|
||||
302,
|
||||
&[
|
||||
("Content-Type", "text/html"),
|
||||
("Location", "http://localhost:__PORT__/new/path"),
|
||||
],
|
||||
"",
|
||||
);
|
||||
let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response_template).await;
|
||||
|
||||
let result = proxy.forward(port, "/old", DocType::Ppt, &[]).await.unwrap();
|
||||
|
||||
assert_eq!(result.status, 302);
|
||||
let location = result
|
||||
.headers
|
||||
.iter()
|
||||
.find(|(k, _)| k == "location")
|
||||
.map(|(_, v)| v.as_str());
|
||||
assert_eq!(location, Some(format!("/api/ppt-proxy/{port}/new/path").as_str()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RP-6b: Location rewriting for root-relative paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp6b_proxy_rewrites_root_relative_location() {
|
||||
let response_template = build_http_response(302, &[("Content-Type", "text/html"), ("Location", "/redirected")], "");
|
||||
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response_template).await;
|
||||
|
||||
let result = proxy.forward(port, "/old", DocType::Word, &[]).await.unwrap();
|
||||
|
||||
let location = result
|
||||
.headers
|
||||
.iter()
|
||||
.find(|(k, _)| k == "location")
|
||||
.map(|(_, v)| v.as_str());
|
||||
assert_eq!(
|
||||
location,
|
||||
Some(format!("/api/office-watch-proxy/{port}/redirected").as_str())
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proxy forwards 404 status correctly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_forwards_404_status() {
|
||||
let response = build_http_response(404, &[("Content-Type", "text/plain")], "Not Found");
|
||||
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await;
|
||||
|
||||
let result = proxy.forward(port, "/missing", DocType::Word, &[]).await.unwrap();
|
||||
|
||||
assert_eq!(result.status, 404);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
use nomifun_api_types::{PreviewHistoryTargetDto, PreviewSnapshotInfoDto};
|
||||
use nomifun_common::PreviewContentType;
|
||||
use nomifun_office::SnapshotService;
|
||||
|
||||
fn make_target(content_type: PreviewContentType, file_path: Option<&str>) -> PreviewHistoryTargetDto {
|
||||
PreviewHistoryTargetDto {
|
||||
content_type,
|
||||
file_path: file_path.map(String::from),
|
||||
workspace: None,
|
||||
file_name: None,
|
||||
title: None,
|
||||
language: None,
|
||||
conversation_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_target_full(
|
||||
content_type: PreviewContentType,
|
||||
file_path: Option<&str>,
|
||||
workspace: Option<&str>,
|
||||
conversation_id: Option<i64>,
|
||||
) -> PreviewHistoryTargetDto {
|
||||
PreviewHistoryTargetDto {
|
||||
content_type,
|
||||
file_path: file_path.map(String::from),
|
||||
workspace: workspace.map(String::from),
|
||||
file_name: None,
|
||||
title: None,
|
||||
language: None,
|
||||
conversation_id,
|
||||
}
|
||||
}
|
||||
|
||||
// SH-1: Save snapshot
|
||||
#[tokio::test]
|
||||
async fn sh1_save_snapshot() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
|
||||
let info = svc.save(&target, "# Hello").await.unwrap();
|
||||
|
||||
assert!(!info.id.is_empty(), "id must not be empty");
|
||||
assert!(info.created_at > 0, "createdAt must be current timestamp");
|
||||
assert_eq!(info.size, 7, "size must be content byte count");
|
||||
assert_eq!(info.content_type, PreviewContentType::Markdown);
|
||||
assert!(!info.label.is_empty(), "label must not be empty");
|
||||
}
|
||||
|
||||
// SH-2: List snapshots (ordered by createdAt)
|
||||
#[tokio::test]
|
||||
async fn sh2_list_snapshots_ordered() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
|
||||
let s1 = svc.save(&target, "content-1").await.unwrap();
|
||||
let s2 = svc.save(&target, "content-2").await.unwrap();
|
||||
let s3 = svc.save(&target, "content-3").await.unwrap();
|
||||
|
||||
let list = svc.list(&target).await.unwrap();
|
||||
assert_eq!(list.len(), 3);
|
||||
|
||||
assert_eq!(list[0].id, s1.id);
|
||||
assert_eq!(list[1].id, s2.id);
|
||||
assert_eq!(list[2].id, s3.id);
|
||||
assert!(list[0].created_at <= list[1].created_at);
|
||||
assert!(list[1].created_at <= list[2].created_at);
|
||||
}
|
||||
|
||||
// SH-3: Get snapshot content
|
||||
#[tokio::test]
|
||||
async fn sh3_get_snapshot_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
|
||||
let info = svc.save(&target, "# Hello").await.unwrap();
|
||||
let resp = svc.get_content(&target, &info.id).await.unwrap();
|
||||
|
||||
assert!(resp.is_some());
|
||||
let resp = resp.unwrap();
|
||||
assert_eq!(resp.content, "# Hello");
|
||||
assert_eq!(resp.snapshot.id, info.id);
|
||||
assert_eq!(resp.snapshot.size, info.size);
|
||||
assert_eq!(resp.snapshot.content_type, PreviewContentType::Markdown);
|
||||
}
|
||||
|
||||
// SH-4: Get nonexistent snapshot returns None
|
||||
#[tokio::test]
|
||||
async fn sh4_get_nonexistent_snapshot() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
|
||||
let resp = svc.get_content(&target, "nonexistent").await.unwrap();
|
||||
assert!(resp.is_none());
|
||||
}
|
||||
|
||||
// SH-4b: Get nonexistent snapshot from a target with existing snapshots
|
||||
#[tokio::test]
|
||||
async fn sh4b_get_nonexistent_snapshot_with_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
|
||||
svc.save(&target, "some content").await.unwrap();
|
||||
let resp = svc.get_content(&target, "does-not-exist").await.unwrap();
|
||||
assert!(resp.is_none());
|
||||
}
|
||||
|
||||
// SH-5: Trim snapshots over limit (50)
|
||||
#[tokio::test]
|
||||
async fn sh5_trim_over_limit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Code, Some("/c.rs"));
|
||||
|
||||
let mut first_ids: Vec<String> = Vec::new();
|
||||
for i in 0..51 {
|
||||
let info = svc.save(&target, &format!("content-{i}")).await.unwrap();
|
||||
if i == 0 {
|
||||
first_ids.push(info.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let list = svc.list(&target).await.unwrap();
|
||||
assert_eq!(list.len(), 50, "must trim to 50 snapshots");
|
||||
|
||||
assert!(
|
||||
!list.iter().any(|s| s.id == first_ids[0]),
|
||||
"oldest snapshot must be removed"
|
||||
);
|
||||
}
|
||||
|
||||
// SH-5b: Verify snapshot file is also deleted
|
||||
#[tokio::test]
|
||||
async fn sh5b_trim_deletes_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Code, Some("/c.rs"));
|
||||
|
||||
let first = svc.save(&target, "first").await.unwrap();
|
||||
for i in 1..51 {
|
||||
svc.save(&target, &format!("content-{i}")).await.unwrap();
|
||||
}
|
||||
|
||||
let resp = svc.get_content(&target, &first.id).await.unwrap();
|
||||
assert!(resp.is_none(), "trimmed snapshot file must not be readable");
|
||||
}
|
||||
|
||||
// SH-6: Different targets are isolated
|
||||
#[tokio::test]
|
||||
async fn sh6_different_targets_isolated() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let t1 = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
let t2 = make_target(PreviewContentType::Markdown, Some("/b.md"));
|
||||
|
||||
svc.save(&t1, "content-a").await.unwrap();
|
||||
svc.save(&t2, "content-b1").await.unwrap();
|
||||
svc.save(&t2, "content-b2").await.unwrap();
|
||||
|
||||
let list1 = svc.list(&t1).await.unwrap();
|
||||
let list2 = svc.list(&t2).await.unwrap();
|
||||
assert_eq!(list1.len(), 1);
|
||||
assert_eq!(list2.len(), 2);
|
||||
}
|
||||
|
||||
// SH-6b: Different content types are isolated
|
||||
#[tokio::test]
|
||||
async fn sh6b_different_content_types_isolated() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let t1 = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
let t2 = make_target(PreviewContentType::Html, Some("/a.md"));
|
||||
|
||||
svc.save(&t1, "md content").await.unwrap();
|
||||
svc.save(&t2, "html content").await.unwrap();
|
||||
|
||||
let list1 = svc.list(&t1).await.unwrap();
|
||||
let list2 = svc.list(&t2).await.unwrap();
|
||||
assert_eq!(list1.len(), 1);
|
||||
assert_eq!(list2.len(), 1);
|
||||
}
|
||||
|
||||
// SH-7: Target field combination produces different SHA-1 directories
|
||||
#[tokio::test]
|
||||
async fn sh7_target_field_combination_different_hash() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
|
||||
let t1 = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
let t2 = make_target_full(PreviewContentType::Markdown, Some("/a.md"), Some("/ws"), Some(1));
|
||||
|
||||
svc.save(&t1, "content-1").await.unwrap();
|
||||
svc.save(&t2, "content-2").await.unwrap();
|
||||
|
||||
let list1 = svc.list(&t1).await.unwrap();
|
||||
let list2 = svc.list(&t2).await.unwrap();
|
||||
assert_eq!(list1.len(), 1);
|
||||
assert_eq!(list2.len(), 1);
|
||||
|
||||
let r1 = svc.get_content(&t1, &list1[0].id).await.unwrap().unwrap();
|
||||
let r2 = svc.get_content(&t2, &list2[0].id).await.unwrap().unwrap();
|
||||
assert_eq!(r1.content, "content-1");
|
||||
assert_eq!(r2.content, "content-2");
|
||||
}
|
||||
|
||||
// SH-7b: Verify SHA-1 directory naming by inspecting filesystem
|
||||
#[tokio::test]
|
||||
async fn sh7b_sha1_directory_naming() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
|
||||
svc.save(&target, "test").await.unwrap();
|
||||
|
||||
let history_dir = tmp.path().join("preview-history");
|
||||
let mut entries = std::fs::read_dir(&history_dir).unwrap();
|
||||
let dir_entry = entries.next().unwrap().unwrap();
|
||||
let dir_name = dir_entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
assert_eq!(dir_name.len(), 40, "SHA-1 hex must be 40 characters");
|
||||
assert!(
|
||||
dir_name.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"directory name must be hex"
|
||||
);
|
||||
|
||||
let index = std::fs::read_to_string(dir_entry.path().join("index.json")).unwrap();
|
||||
let snapshots: Vec<PreviewSnapshotInfoDto> = serde_json::from_str(&index).unwrap();
|
||||
assert_eq!(snapshots.len(), 1);
|
||||
}
|
||||
|
||||
// Extra: List on empty directory returns empty vec
|
||||
#[tokio::test]
|
||||
async fn list_empty_returns_empty_vec() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Pdf, Some("/doc.pdf"));
|
||||
|
||||
let list = svc.list(&target).await.unwrap();
|
||||
assert!(list.is_empty());
|
||||
}
|
||||
|
||||
// Extra: Save preserves file_name and file_path from target
|
||||
#[tokio::test]
|
||||
async fn save_preserves_target_metadata() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = PreviewHistoryTargetDto {
|
||||
content_type: PreviewContentType::Word,
|
||||
file_path: Some("/docs/report.docx".into()),
|
||||
workspace: None,
|
||||
file_name: Some("report.docx".into()),
|
||||
title: None,
|
||||
language: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let info = svc.save(&target, "word content").await.unwrap();
|
||||
assert_eq!(info.file_path.as_deref(), Some("/docs/report.docx"));
|
||||
assert_eq!(info.file_name.as_deref(), Some("report.docx"));
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_office::StarOfficeDetector;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
fn allocate_port() -> u16 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
async fn mock_star_office_server(
|
||||
port: u16,
|
||||
health_ok: bool,
|
||||
status_body: &'static str,
|
||||
index_body: &'static str,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut stream, _) = match listener.accept().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => break,
|
||||
};
|
||||
let status_body = status_body.to_string();
|
||||
let index_body = index_body.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let n = match stream.read(&mut buf).await {
|
||||
Ok(n) => n,
|
||||
Err(_) => return,
|
||||
};
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
|
||||
let path = request
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.unwrap_or("/");
|
||||
|
||||
let (status_line, body) = match path {
|
||||
"/health" => {
|
||||
if health_ok {
|
||||
("HTTP/1.1 200 OK", "ok".to_string())
|
||||
} else {
|
||||
("HTTP/1.1 503 Service Unavailable", "down".to_string())
|
||||
}
|
||||
}
|
||||
"/status" => ("HTTP/1.1 200 OK", status_body),
|
||||
"/" => ("HTTP/1.1 200 OK", index_body),
|
||||
_ => ("HTTP/1.1 404 Not Found", "not found".to_string()),
|
||||
};
|
||||
|
||||
let response = format!(
|
||||
"{status_line}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.shutdown().await;
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SO-1: No available service → returns None
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn so1_no_service_returns_none() {
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let port = allocate_port();
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(50)).await;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SO-2: With preferred URL, no service → returns None
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn so2_preferred_url_no_service() {
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let result = detector
|
||||
.detect_exact(Some("http://localhost:59990"), true, Some(50))
|
||||
.await;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SO-3: Cache behavior — second call hits cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn so3_cache_hit_returns_cached() {
|
||||
let port = allocate_port();
|
||||
let url = format!("http://localhost:{port}");
|
||||
let detector = Arc::new(StarOfficeDetector::new(reqwest::Client::new()));
|
||||
|
||||
let _ = detector.detect_exact(Some(&url), false, Some(50)).await;
|
||||
|
||||
let t0 = tokio::time::Instant::now();
|
||||
let result = detector.detect_exact(Some(&url), false, Some(50)).await;
|
||||
let elapsed = t0.elapsed();
|
||||
|
||||
assert!(result.is_none());
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_millis(100),
|
||||
"cached call should be fast, took {elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SO-4: Force ignores cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn so4_force_bypasses_cache() {
|
||||
let port = allocate_port();
|
||||
let url = format!("http://localhost:{port}");
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
|
||||
let _ = detector.detect_exact(Some(&url), false, Some(50)).await;
|
||||
let result = detector.detect_exact(Some(&url), true, Some(50)).await;
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SO-5: Detect available service (three-step health check)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn so5_detect_available_service() {
|
||||
let port = allocate_port();
|
||||
let handle = mock_star_office_server(
|
||||
port,
|
||||
true,
|
||||
r#"{"status": "idle"}"#,
|
||||
"<html><head></head><body>Star Office dashboard with decorate room</body></html>",
|
||||
)
|
||||
.await;
|
||||
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
|
||||
|
||||
assert_eq!(result, Some(format!("http://localhost:{port}")));
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SO-6: Exclude OpenClaw misidentification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn so6_exclude_openclaw() {
|
||||
let port = allocate_port();
|
||||
let handle = mock_star_office_server(
|
||||
port,
|
||||
true,
|
||||
r#"{"status": "idle"}"#,
|
||||
"<html><head></head><body>Star Office with openclaw control panel</body></html>",
|
||||
)
|
||||
.await;
|
||||
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
|
||||
|
||||
assert!(result.is_none());
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check step 1 fail: /health returns non-200
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_step1_fail_returns_none() {
|
||||
let port = allocate_port();
|
||||
let handle = mock_star_office_server(
|
||||
port,
|
||||
false,
|
||||
r#"{"status": "idle"}"#,
|
||||
"<html><body>Star Office</body></html>",
|
||||
)
|
||||
.await;
|
||||
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
|
||||
|
||||
assert!(result.is_none());
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check step 2 fail: /status has no status markers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_step2_no_status_markers() {
|
||||
let port = allocate_port();
|
||||
let handle = mock_star_office_server(
|
||||
port,
|
||||
true,
|
||||
"just some random text",
|
||||
"<html><body>Star Office</body></html>",
|
||||
)
|
||||
.await;
|
||||
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
|
||||
|
||||
assert!(result.is_none());
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check step 3 fail: / has no feature keywords
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_step3_no_feature_keywords() {
|
||||
let port = allocate_port();
|
||||
let handle = mock_star_office_server(
|
||||
port,
|
||||
true,
|
||||
r#"{"status": "idle"}"#,
|
||||
"<html><body>Some other application</body></html>",
|
||||
)
|
||||
.await;
|
||||
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
|
||||
|
||||
assert!(result.is_none());
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detect with different status markers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_with_writing_status() {
|
||||
let port = allocate_port();
|
||||
let handle = mock_star_office_server(
|
||||
port,
|
||||
true,
|
||||
r#"{"status": "writing"}"#,
|
||||
"<html><body>decorate room and asset sidebar</body></html>",
|
||||
)
|
||||
.await;
|
||||
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
|
||||
|
||||
assert_eq!(result, Some(format!("http://localhost:{port}")));
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache stores hit correctly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_stores_hit_after_success() {
|
||||
let port = allocate_port();
|
||||
let handle =
|
||||
mock_star_office_server(port, true, r#"idle"#, "<html><body>star office dashboard</body></html>").await;
|
||||
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
|
||||
assert!(result.is_some());
|
||||
|
||||
handle.abort();
|
||||
|
||||
let cached = detector.detect_exact(Some(&url), false, Some(50)).await;
|
||||
assert_eq!(cached, result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache miss TTL expires quickly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_miss_ttl_expires() {
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let port = allocate_port();
|
||||
let url = format!("http://localhost:{port}");
|
||||
|
||||
let _ = detector.detect_exact(Some(&url), false, Some(50)).await;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1600)).await;
|
||||
|
||||
let port2 = allocate_port();
|
||||
let handle = mock_star_office_server(port2, true, "idle", "<html><body>star office</body></html>").await;
|
||||
|
||||
let url2 = format!("http://localhost:{port2}");
|
||||
let result = detector.detect_exact(Some(&url2), false, Some(2000)).await;
|
||||
assert_eq!(result, Some(format!("http://localhost:{port2}")));
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_office::{DocType, OfficeError, OfficecliWatchManager, ProcessHandle, ProcessSpawner};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test doubles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct MockHandle {
|
||||
alive: AtomicBool,
|
||||
}
|
||||
|
||||
impl MockHandle {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
alive: AtomicBool::new(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessHandle for MockHandle {
|
||||
fn kill(&self) {
|
||||
self.alive.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn is_alive(&self) -> bool {
|
||||
self.alive.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
struct TestSpawner {
|
||||
installed: AtomicBool,
|
||||
spawn_count: AtomicU32,
|
||||
install_count: AtomicU32,
|
||||
}
|
||||
|
||||
impl TestSpawner {
|
||||
fn new(installed: bool) -> Self {
|
||||
Self {
|
||||
installed: AtomicBool::new(installed),
|
||||
spawn_count: AtomicU32::new(0),
|
||||
install_count: AtomicU32::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ProcessSpawner for TestSpawner {
|
||||
async fn spawn_officecli(
|
||||
&self,
|
||||
_file_path: &str,
|
||||
port: u16,
|
||||
_doc_type: DocType,
|
||||
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
|
||||
self.spawn_count.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if !self.installed.load(Ordering::SeqCst) {
|
||||
return Err(OfficeError::OfficecliNotFound);
|
||||
}
|
||||
|
||||
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||
.map_err(|e| OfficeError::StartFailed(e.to_string()))?;
|
||||
std::mem::forget(listener);
|
||||
|
||||
Ok(Box::new(MockHandle::new()))
|
||||
}
|
||||
|
||||
async fn install_officecli(&self) -> Result<(), OfficeError> {
|
||||
self.install_count.fetch_add(1, Ordering::SeqCst);
|
||||
self.installed.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_officecli_installed(&self) -> bool {
|
||||
self.installed.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct TestBroadcaster {
|
||||
events: std::sync::Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl TestBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: std::sync::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn event_names(&self) -> Vec<String> {
|
||||
self.events.lock().unwrap().iter().map(|e| e.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn event_states(&self) -> Vec<String> {
|
||||
self.events
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|e| e.data["state"].as_str().map(String::from))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for TestBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_temp_file(dir: &tempfile::TempDir, name: &str) -> String {
|
||||
let path = dir.path().join(name);
|
||||
std::fs::write(&path, b"test content").unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WP-2: Session reuse (same file, same doc type)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn wp2_session_reuse_returns_same_port() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner.clone(), broadcaster);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_temp_file(&dir, "doc.docx");
|
||||
|
||||
let p1 = mgr.start(&path, DocType::Word).await.unwrap();
|
||||
let p2 = mgr.start(&path, DocType::Word).await.unwrap();
|
||||
|
||||
assert_eq!(p1, p2);
|
||||
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WP-3: Stop removes session and kills process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn wp3_stop_terminates_session() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_temp_file(&dir, "doc.docx");
|
||||
|
||||
let port = mgr.start(&path, DocType::Word).await.unwrap();
|
||||
assert!(mgr.is_active_port(port, DocType::Word));
|
||||
|
||||
mgr.stop(&path, DocType::Word).await;
|
||||
assert!(!mgr.is_active_port(port, DocType::Word));
|
||||
assert_eq!(mgr.active_session_count(), 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WP-4: Auto-install when officecli not found
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn wp4_auto_install_on_not_found() {
|
||||
let spawner = Arc::new(TestSpawner::new(false));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner.clone(), broadcaster.clone());
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_temp_file(&dir, "doc.docx");
|
||||
|
||||
let port = mgr.start(&path, DocType::Word).await.unwrap();
|
||||
assert!(port > 0);
|
||||
assert_eq!(spawner.install_count.load(Ordering::SeqCst), 1);
|
||||
|
||||
let states = broadcaster.event_states();
|
||||
assert!(states.contains(&"starting".to_string()));
|
||||
assert!(states.contains(&"installing".to_string()));
|
||||
assert!(states.contains(&"ready".to_string()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EP-1: Excel uses independent session pool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn ep1_excel_independent_session_pool() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_temp_file(&dir, "data.xlsx");
|
||||
|
||||
let word_port = mgr.start(&path, DocType::Word).await.unwrap();
|
||||
let excel_port = mgr.start(&path, DocType::Excel).await.unwrap();
|
||||
|
||||
assert_ne!(word_port, excel_port);
|
||||
assert_eq!(mgr.active_session_count(), 2);
|
||||
assert!(mgr.is_active_port(word_port, DocType::Word));
|
||||
assert!(mgr.is_active_port(excel_port, DocType::Excel));
|
||||
assert!(!mgr.is_active_port(word_port, DocType::Excel));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PP-1: PPT uses independent session pool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn pp1_ppt_independent_session_pool() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_temp_file(&dir, "slides.pptx");
|
||||
|
||||
let port = mgr.start(&path, DocType::Ppt).await.unwrap();
|
||||
assert!(port > 0);
|
||||
assert!(mgr.is_active_port(port, DocType::Ppt));
|
||||
assert!(!mgr.is_active_port(port, DocType::Word));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PP-3: PPT triggers background version check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn pp3_ppt_background_version_check() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_temp_file(&dir, "slides.pptx");
|
||||
|
||||
mgr.start(&path, DocType::Ppt).await.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// Version check is fire-and-forget; we just verify it doesn't panic
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status event naming per doc type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_events_use_correct_prefix() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner, broadcaster.clone());
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let f1 = create_temp_file(&dir, "a.docx");
|
||||
let f2 = create_temp_file(&dir, "b.xlsx");
|
||||
let f3 = create_temp_file(&dir, "c.pptx");
|
||||
|
||||
mgr.start(&f1, DocType::Word).await.unwrap();
|
||||
mgr.start(&f2, DocType::Excel).await.unwrap();
|
||||
mgr.start(&f3, DocType::Ppt).await.unwrap();
|
||||
|
||||
let names = broadcaster.event_names();
|
||||
assert!(names.contains(&"word-preview.status".to_string()));
|
||||
assert!(names.contains(&"excel-preview.status".to_string()));
|
||||
assert!(names.contains(&"ppt-preview.status".to_string()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stop_all lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_all_clears_all_sessions() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let f1 = create_temp_file(&dir, "a.docx");
|
||||
let f2 = create_temp_file(&dir, "b.xlsx");
|
||||
let f3 = create_temp_file(&dir, "c.pptx");
|
||||
|
||||
mgr.start(&f1, DocType::Word).await.unwrap();
|
||||
mgr.start(&f2, DocType::Excel).await.unwrap();
|
||||
mgr.start(&f3, DocType::Ppt).await.unwrap();
|
||||
assert_eq!(mgr.active_session_count(), 3);
|
||||
|
||||
mgr.stop_all();
|
||||
assert_eq!(mgr.active_session_count(), 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSRF defense: is_active_port returns false for non-active ports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp2_inactive_port_rejected() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
|
||||
|
||||
assert!(!mgr.is_active_port(8080, DocType::Word));
|
||||
assert!(!mgr.is_active_port(9999, DocType::Ppt));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stop then restart creates new session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_then_restart_creates_new_session() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner.clone(), broadcaster);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_temp_file(&dir, "doc.docx");
|
||||
|
||||
let p1 = mgr.start(&path, DocType::Word).await.unwrap();
|
||||
mgr.stop(&path, DocType::Word).await;
|
||||
|
||||
let p2 = mgr.start(&path, DocType::Word).await.unwrap();
|
||||
assert_ne!(p1, p2);
|
||||
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
Reference in New Issue
Block a user