mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-15 03:37:03 -04:00
Carry selected tool actions through native file preparation, password retries and conversion, and defer startup intake until pending native opens finish. Await observable completion in native opening, password, recovery and linked-PDF tests while retaining their content, identity, dirty-state, cancellation and destination checks. Verify the expected validation error and preserve the original document after refused consent targets. M3; refs #1914.
226 lines
11 KiB
Rust
226 lines
11 KiB
Rust
use pdfcraft_automation::{Automation, Content};
|
|
use pdfcraft_engine::Session;
|
|
use serde_json::{Value, json};
|
|
|
|
fn result(auto: &mut Automation, args: Value) -> Value {
|
|
match auto.call("doc_batch", &args).unwrap().remove(0) {
|
|
Content::Json(value) => value,
|
|
_ => panic!("expected a report"),
|
|
}
|
|
}
|
|
|
|
fn pdf() -> Vec<u8> {
|
|
Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()
|
|
}
|
|
|
|
#[test]
|
|
fn batch_recurses_preserves_paths_continues_errors_and_never_replaces_outputs() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
|
|
std::fs::create_dir_all(dir.path().join("input/output/sub")).unwrap();
|
|
std::fs::write(dir.path().join("input/a.txt"), "Hello").unwrap();
|
|
std::fs::write(dir.path().join("input/b.pdf"), b"broken").unwrap();
|
|
std::fs::write(dir.path().join("input/sub/a.txt"), "World").unwrap();
|
|
std::fs::write(dir.path().join("input/notes.bin"), b"ignore").unwrap();
|
|
std::fs::write(dir.path().join("input/output/sub/a.pdf"), b"precious").unwrap();
|
|
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
|
let args = json!({"paths":["input","input/a.txt"],"steps":["convert","optimize"],"out_dir":"input/output","recursive":true});
|
|
let report = result(&mut auto, args.clone());
|
|
assert_eq!(report["completed"], 2, "{report}");
|
|
assert_eq!(report["failed"], 1);
|
|
assert_eq!(report["skipped"], 1);
|
|
assert_eq!(std::fs::read(dir.path().join("input/output/sub/a.pdf")).unwrap(), b"precious");
|
|
assert_eq!(std::fs::read_to_string(dir.path().join("input/a.txt")).unwrap(), "Hello");
|
|
for name in ["a.pdf", "sub/a (2).pdf"] {
|
|
let bytes = std::fs::read(dir.path().join("input/output").join(name)).unwrap();
|
|
let mut session = Session::new();
|
|
let id = session.open(name, None, std::sync::Arc::new(bytes), None).unwrap();
|
|
assert_eq!(session.get(id).unwrap().info.pages.len(), 1);
|
|
}
|
|
assert!(!dir.path().join("input/output/b.pdf").exists());
|
|
assert!(auto.session().docs().is_empty());
|
|
let again = result(&mut auto, args);
|
|
assert_eq!(again["completed"], 2, "previous outputs must not become new inputs: {again}");
|
|
assert!(dir.path().join("input/output/a (2).pdf").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn batch_is_nonrecursive_by_default_and_stop_reports_unprocessed_files() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
|
|
std::fs::write(dir.path().join("input/0.pdf"), b"broken").unwrap();
|
|
std::fs::write(dir.path().join("input/1.pdf"), pdf()).unwrap();
|
|
std::fs::write(dir.path().join("input/sub/2.pdf"), pdf()).unwrap();
|
|
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
|
let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","stop_on_error":true}));
|
|
assert_eq!(report["failed"], 1);
|
|
assert_eq!(report["completed"], 0);
|
|
assert_eq!(report["skipped"], 1);
|
|
assert_eq!(report["stopped"], true);
|
|
assert!(!dir.path().join("out").exists());
|
|
let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out"}));
|
|
assert_eq!(report["completed"], 1);
|
|
assert!(!dir.path().join("out/sub").exists());
|
|
assert!(std::fs::metadata(dir.path().join("out/1.pdf")).unwrap().len() <= pdf().len() as u64);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_batch_arguments_and_outside_paths_write_nothing() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir(dir.path().join("input")).unwrap();
|
|
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
|
for change in [
|
|
json!({"steps":["convert","convert"]}),
|
|
json!({"steps":["optimize","convert"]}),
|
|
json!({"steps":[]}),
|
|
json!({"steps":["nope"]}),
|
|
json!({"recursive":"yes"}),
|
|
json!({"paths":[]}),
|
|
json!({"paths":["input","../outside"]}),
|
|
json!({"out_dir":"../outside"}),
|
|
json!({"out_dir":"input"}),
|
|
json!({"out_dir":"."}),
|
|
json!({"quality":0}),
|
|
json!({"quality":"80"}),
|
|
json!({"ppi":2000}),
|
|
json!({"steps":["ocr"],"language":"not-a-language"}),
|
|
json!({"steps":["ocr"],"dpi":1}),
|
|
] {
|
|
let mut args = json!({"paths":["input"],"steps":["optimize"],"out_dir":"out"});
|
|
args.as_object_mut().unwrap().extend(change.as_object().unwrap().clone());
|
|
assert!(auto.call("doc_batch", &args).is_err(), "{args}");
|
|
assert!(!dir.path().join("out").exists(), "invalid arguments must be refused before writing");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn explicitly_selected_subfolder_is_processed_without_recursive() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
|
|
std::fs::write(dir.path().join("input/sub/page.pdf"), pdf()).unwrap();
|
|
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
|
let report = result(&mut auto, json!({"paths":["input","input/sub"],"steps":["convert"],"out_dir":"out"}));
|
|
assert_eq!(report["completed"], 1, "{report}");
|
|
assert!(dir.path().join("out/page.pdf").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_file_can_write_a_numbered_copy_beside_its_source() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap();
|
|
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
|
let report = result(&mut auto, json!({"paths":["page.pdf"],"steps":["convert"],"out_dir":"."}));
|
|
assert_eq!(report["completed"], 1, "{report}");
|
|
assert_eq!(std::fs::read(dir.path().join("page.pdf")).unwrap(), pdf());
|
|
assert_eq!(std::fs::read(dir.path().join("page (2).pdf")).unwrap(), pdf());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn non_utf8_discovered_names_are_reported_without_panicking() {
|
|
use std::os::unix::ffi::OsStrExt;
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir(dir.path().join("input")).unwrap();
|
|
let invalid = dir.path().join("input").join(std::ffi::OsStr::from_bytes(b"bad-\xff.pdf"));
|
|
if let Err(error) = std::fs::write(&invalid, pdf()) {
|
|
// APFS rejects this filename before batch discovery can see it. Keep the
|
|
// discovery assertions on filesystems that allow it; no other I/O error
|
|
// is an unsupported fixture.
|
|
#[cfg(target_os = "macos")]
|
|
if error.raw_os_error() == Some(92) {
|
|
assert!(!invalid.exists());
|
|
assert_eq!(std::fs::read_dir(dir.path().join("input")).unwrap().count(), 0);
|
|
assert!(!dir.path().join("out").exists());
|
|
let valid = dir.path().join("input/valid-λ.pdf");
|
|
std::fs::write(&valid, pdf()).unwrap();
|
|
assert_eq!(std::fs::read(&valid).unwrap(), pdf());
|
|
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
|
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out"}));
|
|
assert_eq!(report["completed"], 1, "{report}");
|
|
assert_eq!(report["failed"], 0, "{report}");
|
|
assert_eq!(std::fs::read(dir.path().join("out/valid-λ.pdf")).unwrap(), pdf());
|
|
eprintln!(
|
|
"non-UTF-8 fixture capability unavailable: macOS filesystem rejected creation with EILSEQ92; valid-Unicode batch companion passed; invalid-name discovery still requires Linux/FreeBSD or a supporting filesystem"
|
|
);
|
|
return;
|
|
}
|
|
panic!("could not create the non-UTF-8 fixture: {error}");
|
|
}
|
|
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
|
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out"}));
|
|
assert_eq!(report["failed"], 1);
|
|
assert!(report["files"][0]["error"].as_str().unwrap().contains("UTF-8"));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn directory_links_are_not_followed_and_output_links_cannot_escape() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
|
|
std::fs::create_dir(dir.path().join("outside")).unwrap();
|
|
std::fs::create_dir(dir.path().join("out")).unwrap();
|
|
std::fs::write(dir.path().join("input/sub/page.pdf"), pdf()).unwrap();
|
|
std::os::unix::fs::symlink(dir.path().join("input"), dir.path().join("input/loop")).unwrap();
|
|
std::os::unix::fs::symlink(dir.path().join("outside"), dir.path().join("out/sub")).unwrap();
|
|
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
|
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out","recursive":true}));
|
|
assert_eq!(report["skipped"], 1);
|
|
assert_eq!(report["failed"], 1);
|
|
assert_eq!(std::fs::read_dir(dir.path().join("outside")).unwrap().count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_ocr_uses_existing_models_or_reports_the_missing_models_per_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap();
|
|
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
|
let report = result(&mut auto, json!({"paths":["page.pdf"],"steps":["ocr","optimize"],"out_dir":"out"}));
|
|
if pdfcraft_engine::ocr::available() {
|
|
assert_eq!(report["completed"], 1, "{report}");
|
|
assert_eq!(report["files"][0]["steps"][0]["skipped_pages"], 1);
|
|
assert!(dir.path().join("out/page.pdf").exists());
|
|
} else {
|
|
assert_eq!(report["failed"], 1, "{report}");
|
|
assert!(report["files"][0]["error"].as_str().unwrap().contains("model"), "{report}");
|
|
assert!(!dir.path().join("out/page.pdf").exists());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn batch_output_collisions_are_safe_under_concurrent_writers() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap();
|
|
let barrier = std::sync::Barrier::new(2);
|
|
std::thread::scope(|scope| {
|
|
let handles: Vec<_> = (0..2)
|
|
.map(|_| {
|
|
scope.spawn(|| {
|
|
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
|
barrier.wait();
|
|
result(&mut auto, json!({"paths":["page.pdf"],"steps":["convert"],"out_dir":"out"}))
|
|
})
|
|
})
|
|
.collect();
|
|
let reports: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
|
assert!(reports.iter().all(|r| r["completed"] == 1), "{reports:?}");
|
|
assert_ne!(reports[0]["files"][0]["output"], reports[1]["files"][0]["output"]);
|
|
});
|
|
for name in ["page.pdf", "page (2).pdf"] {
|
|
assert_eq!(std::fs::read(dir.path().join("out").join(name)).unwrap(), pdf());
|
|
}
|
|
assert_eq!(std::fs::read_dir(dir.path().join("out")).unwrap().count(), 2, "no staging files remain");
|
|
}
|
|
|
|
#[cfg(feature = "mcp")]
|
|
#[test]
|
|
fn mcp_batch_errors_keep_the_structured_per_file_report() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(dir.path().join("broken.pdf"), b"broken").unwrap();
|
|
let input = json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"doc_batch","arguments":{"paths":["broken.pdf"],"steps":["optimize"],"out_dir":"out"}}}).to_string() + "\n";
|
|
let mut out = Vec::new();
|
|
pdfcraft_automation::mcp::McpServer::new(Automation::new().with_root(dir.path()).unwrap()).serve(input.as_bytes(), &mut out).unwrap();
|
|
let report: Value = serde_json::from_slice(&out).unwrap();
|
|
assert_eq!(report["result"]["isError"], true, "{report}");
|
|
assert_eq!(report["result"]["structuredContent"]["failed"], 1);
|
|
}
|