| 1 | use super::*; |
| 2 | use crate::tools::spec::{ToolExecutionOutcome, ToolTerminalStatus}; |
| 3 | |
| 4 | #[tokio::test] |
| 5 | async fn missing_binary_maps_to_bounded_failed_machine_contract() { |
| 6 | let temporary = tempfile::tempdir().expect("tempdir"); |
| 7 | let missing = temporary.path().join("definitely-not-pdftotext"); |
| 8 | let input = temporary.path().join("input.pdf"); |
| 9 | std::fs::write(&input, b"%PDF-1.7\n%%EOF").expect("fixture"); |
| 10 | let error = extract_path( |
| 11 | &input, |
| 12 | None, |
| 13 | PdfTextCommand::test(missing.as_os_str(), Duration::from_secs(1), None), |
| 14 | ) |
| 15 | .await |
| 16 | .expect_err("missing binary"); |
| 17 | assert_eq!(error, PdfTextError::BinaryUnavailable); |
| 18 | |
| 19 | let error = into_tool_error(error); |
| 20 | let payload = match &error { |
| 21 | ToolError::NotAvailable { message } => { |
| 22 | assert!(message.len() < 512, "{message}"); |
| 23 | serde_json::from_str::<serde_json::Value>(message).expect("JSON failure payload") |
| 24 | } |
| 25 | other => panic!("unexpected mapped error: {other:?}"), |
| 26 | }; |
| 27 | assert_eq!(payload["type"], "binary_unavailable"); |
| 28 | assert_eq!(payload["binary"], "pdftotext"); |
| 29 | assert_eq!( |
| 30 | ToolExecutionOutcome::from_legacy(Err(error)).status, |
| 31 | ToolTerminalStatus::Failed |
| 32 | ); |
| 33 | } |
| 34 | |
| 35 | #[cfg(unix)] |
| 36 | fn executable_script(contents: &str) -> (tempfile::TempDir, std::path::PathBuf) { |
| 37 | use std::os::unix::fs::PermissionsExt; |
| 38 | |
| 39 | let temporary = tempfile::tempdir().expect("tempdir"); |
| 40 | let binary = temporary.path().join("fake-pdftotext"); |
| 41 | std::fs::write(&binary, contents).expect("fake binary"); |
| 42 | let mut permissions = std::fs::metadata(&binary).expect("metadata").permissions(); |
| 43 | permissions.set_mode(0o700); |
| 44 | std::fs::set_permissions(&binary, permissions).expect("executable"); |
| 45 | (temporary, binary) |
| 46 | } |
| 47 | |
| 48 | #[cfg(unix)] |
| 49 | #[tokio::test] |
| 50 | async fn shared_adapter_forwards_page_window_and_returns_stdout() { |
| 51 | let (temporary, binary) = executable_script( |
| 52 | "#!/bin/sh\nprintf 'args:%s\\n' \"$*\"\nprintf 'page one\\fpage two\\n'\n", |
| 53 | ); |
| 54 | let request = PdfTextCommand::test(binary.as_os_str(), Duration::from_secs(1), None); |
| 55 | let input = temporary.path().join("input with spaces.pdf"); |
| 56 | std::fs::write(&input, b"fixture bytes").expect("fixture"); |
| 57 | let text = extract_path(&input, Some((2, 4)), request) |
| 58 | .await |
| 59 | .expect("fake extraction"); |
| 60 | assert!(text.contains("-layout -f 2 -l 4"), "{text}"); |
| 61 | assert!(text.contains(input.to_string_lossy().as_ref()), "{text}"); |
| 62 | assert!(text.ends_with("page one\u{c}page two\n"), "{text:?}"); |
| 63 | |
| 64 | let staged = extract_bytes(b"fetched fixture bytes", request) |
| 65 | .await |
| 66 | .expect("fake fetched extraction"); |
| 67 | assert!(staged.contains("-layout"), "{staged}"); |
| 68 | assert!(staged.ends_with("page one\u{c}page two\n"), "{staged:?}"); |
| 69 | } |
| 70 | |
| 71 | #[cfg(unix)] |
| 72 | #[tokio::test] |
| 73 | async fn child_execution_is_timeout_and_cancellation_bounded() { |
| 74 | let (temporary, binary) = executable_script("#!/bin/sh\nexec sleep 10\n"); |
| 75 | let input = temporary.path().join("input.pdf"); |
| 76 | std::fs::write(&input, b"fixture bytes").expect("fixture"); |
| 77 | let started = std::time::Instant::now(); |
| 78 | let error = extract_path( |
| 79 | &input, |
| 80 | None, |
| 81 | PdfTextCommand::test(binary.as_os_str(), Duration::from_millis(50), None), |
| 82 | ) |
| 83 | .await |
| 84 | .expect_err("timeout"); |
| 85 | assert_eq!(error, PdfTextError::TimedOut); |
| 86 | assert!(started.elapsed() < Duration::from_secs(2)); |
| 87 | |
| 88 | let cancel = CancellationToken::new(); |
| 89 | let cancel_after_spawn = cancel.clone(); |
| 90 | tokio::spawn(async move { |
| 91 | tokio::time::sleep(Duration::from_millis(50)).await; |
| 92 | cancel_after_spawn.cancel(); |
| 93 | }); |
| 94 | let started = std::time::Instant::now(); |
| 95 | let error = extract_path( |
| 96 | &input, |
| 97 | None, |
| 98 | PdfTextCommand::test(binary.as_os_str(), Duration::from_secs(10), Some(&cancel)), |
| 99 | ) |
| 100 | .await |
| 101 | .expect_err("cancelled"); |
| 102 | assert_eq!(error, PdfTextError::Cancelled); |
| 103 | assert!(started.elapsed() < Duration::from_secs(2)); |
| 104 | } |
| 105 | |
| 106 | #[tokio::test] |
| 107 | async fn bounded_reader_drains_but_retains_only_the_prefix() { |
| 108 | let output = read_bounded(&b"0123456789"[..], 4).await.expect("read"); |
| 109 | assert_eq!(output.bytes, b"0123"); |
| 110 | assert!(output.truncated); |
| 111 | } |
| 112 | |
| 113 | #[test] |
| 114 | fn stderr_sanitizer_removes_terminal_control_bytes() { |
| 115 | assert_eq!(sanitized_text(b"bad\x1b[31m\0\nnext"), "bad�[31m�\nnext"); |
| 116 | } |
| 117 |